feat: make PR creation policy opt-in (#1334)

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
Co-authored-by: Johannes du Plessis <51395795+johannes117@users.noreply.github.com>
This commit is contained in:
open-swe[bot] 2026-05-26 13:30:18 -07:00 • committed by GitHub
parent aaeed1d95f
commit e347aed851
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 144 additions and 97 deletions

View file

@ -93,7 +93,7 @@ Model + reasoning effort are resolved per run in this precedence (highest wins):
2. Per-user dashboard profile override (`agent/dashboard/agent_overrides.py:load_profile`), keyed by resolved GitHub login.
3. Team default model (`agent/dashboard/team_settings.py:get_team_default_model("agent")`).
Supported model IDs and per-model effort/reasoning rules live in `agent/dashboard/options.py`. Profile flags also drive run behavior — e.g. `profile_create_prs` disables PR creation for users who've opted out. Model construction goes through `agent/utils/model.py` (`make_model`, `provider_model_kwargs`, `fallback_model_id_for`).
Supported model IDs and per-model effort/reasoning rules live in `agent/dashboard/options.py`. Profile flags also drive run behavior — e.g. `profile_create_prs` enables the opt-in Always Create PRs policy. Model construction goes through `agent/utils/model.py` (`make_model`, `provider_model_kwargs`, `fallback_model_id_for`).
### Auth

View file

@ -93,7 +93,7 @@ Model + reasoning effort are resolved per run in this precedence (highest wins):
2. Per-user dashboard profile override (`agent/dashboard/agent_overrides.py:load_profile`), keyed by resolved GitHub login.
3. Team default model (`agent/dashboard/team_settings.py:get_team_default_model("agent")`).
Supported model IDs and per-model effort/reasoning rules live in `agent/dashboard/options.py`. Profile flags also drive run behavior — e.g. `profile_create_prs` disables PR creation for users who've opted out. Model construction goes through `agent/utils/model.py` (`make_model`, `provider_model_kwargs`, `fallback_model_id_for`).
Supported model IDs and per-model effort/reasoning rules live in `agent/dashboard/options.py`. Profile flags also drive run behavior — e.g. `profile_create_prs` enables the opt-in Always Create PRs policy. Model construction goes through `agent/utils/model.py` (`make_model`, `provider_model_kwargs`, `fallback_model_id_for`).
### Auth

View file

@ -73,13 +73,13 @@ async def load_profile(login: str) -> dict[str, Any] | None:
def profile_create_prs(profile: dict[str, Any] | None) -> bool:
"""Return whether the agent should automatically open a PR. Defaults to True."""
"""Return whether the agent should always open a PR. Defaults to False."""
if not isinstance(profile, dict):
return True
return False
value = profile.get("create_prs")
if isinstance(value, bool):
return value
return True
return False
def normalize_profile_overrides(profile: dict[str, Any]) -> tuple[str | None, str | None]:

View file

@ -39,7 +39,7 @@ class ProfileUpdate(BaseModel):
base_branch: str | None = None
branch_prefix: str | None = None
auto_fix_ci: bool = True
create_prs: bool = True
create_prs: bool = False
review_draft_prs: bool | None = None
@field_validator("default_model")

View file

@ -128,12 +128,14 @@ If you make changes, communicate updates in the source channel:
If a Slack-triggered request is asking you to review a GitHub pull request, do not clone the repo, edit files, commit, push, or open a PR. Call `request_pr_review` once with the GitHub PR URL, then use `slack_thread_reply` to say whether the review was started or why it could not be started, and stop.
First decide whether the user is asking for code/repository changes or for information only. Do not create commits, branches, or pull requests for questions, explanations, status checks, or other requests that can be fully answered without changing files.
For tasks that require code changes, follow this order:
1. **Understand** — Read the issue/task carefully. Explore relevant files before making any changes.
2. **Implement** — Make focused, minimal changes. Do not modify code outside the scope of the task. For example: if the task targets Python, do not add JS/TS implementations; if it targets one service or package, do not modify others.
3. **Verify** — Run linters and only tests **directly related to the files you changed**. Do NOT run the full test suite — CI handles that. If no related tests exist, skip this step.
4. **Submit** — Commit, push, and open or update a draft pull request with `GH_TOKEN=dummy gh`.
4. **Submit** — Commit and push your branch. Open or update a draft pull request with `GH_TOKEN=dummy gh` when the user asks for a PR, when a PR is necessary to deliver or review the changes, or when the Always Create PRs dashboard setting is enabled.
5. **Comment** — Call `linear_comment` or `slack_thread_reply` for Linear/Slack. For GitHub-triggered tasks, comment with `GH_TOKEN=dummy gh`.
**Strict requirement:** Never claim "PR updated/opened" unless `gh` returned success and you have the PR URL from command output or `GH_TOKEN=dummy gh pr view --json url --jq .url`. If push or PR creation fails, state that explicitly.
@ -141,7 +143,8 @@ For tasks that require code changes, follow this order:
For questions or status checks (no code changes needed):
1. **Answer** — Gather the information needed to respond.
2. **Comment** — Call `linear_comment` or `slack_thread_reply` for Linear/Slack. For GitHub-triggered tasks, use `GH_TOKEN=dummy gh issue comment` or `GH_TOKEN=dummy gh pr comment`. Never leave a question unanswered."""
2. **Comment** — Call `linear_comment` or `slack_thread_reply` for Linear/Slack. For GitHub-triggered tasks, use `GH_TOKEN=dummy gh issue comment` or `GH_TOKEN=dummy gh pr comment`. Never leave a question unanswered.
3. **Do not submit changes** — Do not commit, push, or open/update a PR unless the user then asks for changes."""
TOOL_USAGE_SECTION = """---
@ -216,7 +219,7 @@ CORE_BEHAVIOR_SECTION = """---
- **Persistence:** Keep working until the current task is completely resolved. Only terminate when you are certain the task is complete.
- **Accuracy:** Never guess or make up information. Always use tools to gather accurate data about files and codebase structure.
- **Autonomy:** Never ask the user for permission mid-task. Run linters, fix errors, push commits, and open/update the draft PR without waiting for confirmation."""
- **Autonomy:** Never ask the user for permission mid-task. For code-change tasks, run linters, fix errors, push commits, and open/update the draft PR without waiting for confirmation when the user asks for a PR, when a PR is necessary, or when the Always Create PRs dashboard setting is enabled. For information-only tasks, answer directly without creating commits or PRs."""
DEPENDENCY_SECTION = """---
@ -275,6 +278,10 @@ COMMIT_PR_SECTION = """---
### Committing Changes and Opening Pull Requests
This section applies only after you have made code or repository changes. For information-only requests, answer in the source channel and do not commit, push, or open/update a PR.
By default, open or update a draft PR when the user asks for one or when a PR is necessary to deliver or review the changes. If a code-change task does not need a PR, still commit and push the branch so the work is preserved, then notify the source channel with the branch URL and summary. If the Always Create PRs dashboard setting is enabled, always open or update a draft PR for code-change tasks.
When you have completed your implementation, follow these steps in order:
1. **Run linters and formatters**: You MUST run the appropriate lint/format commands before submitting:
@ -292,7 +299,7 @@ When you have completed your implementation, follow these steps in order:
2. **Review your changes**: Review the diff to ensure correctness. Verify no regressions or unintended modifications.
3. **Submit via `gh`**: Commit locally, push with `git push origin <branch>`, then use `GH_TOKEN=dummy gh pr create --draft ...` or `GH_TOKEN=dummy gh pr edit ...`.
3. **Submit via `gh`**: Commit locally, push with `git push origin <branch>`, then use `GH_TOKEN=dummy gh pr create --draft ...` or `GH_TOKEN=dummy gh pr edit ...` when a PR is requested, necessary, or required by the Always Create PRs dashboard setting.
If a draft PR already exists for the branch, update it instead of opening a duplicate.
**PR Title** (under 70 characters):
@ -318,7 +325,7 @@ When you have completed your implementation, follow these steps in order:
**Commit message**: Concise, focusing on the "why" rather than the "what". If not provided, the PR title is used.
**IMPORTANT: Never ask the user for permission or confirmation before pushing commits or opening/updating the draft PR. Do not say "if you want, I can proceed" or "shall I open the PR?". When implementation is done and checks pass, push and open/update the PR autonomously.**
**IMPORTANT: For code-change tasks, never ask the user for permission or confirmation before pushing commits or opening/updating a draft PR. Do not say "if you want, I can proceed" or "shall I open the PR?". When implementation is done and checks pass, push autonomously, and open/update a draft PR autonomously when requested, necessary, or required by the Always Create PRs dashboard setting.**
**IMPORTANT: If you made commits directly via `git commit` or `git revert` in the sandbox, you MUST push those commits to GitHub. Never report the work as done without pushing.**
@ -328,7 +335,7 @@ When you have completed your implementation, follow these steps in order:
**IMPORTANT: If `git push` or `gh` returns "403", "Permission denied", or another permanent authorization failure, do not retry. Report the error to the user immediately and stop.**
4. **Notify the source** immediately after PR creation/update succeeds. Include a brief summary and the PR link:
4. **Notify the source** immediately after pushing and, when applicable, PR creation/update succeeds. Include a brief summary plus the PR link or branch URL:
- Linear-triggered: use `linear_comment` with an `@mention` of the user who triggered the task
- Slack-triggered: use `slack_thread_reply`
- GitHub-triggered: use `GH_TOKEN=dummy gh issue comment` or `GH_TOKEN=dummy gh pr comment`
@ -343,7 +350,7 @@ When you have completed your implementation, follow these steps in order:
- <change 2>
```
Always push, open/update the draft PR with `gh`, and notify the appropriate source once implementation is complete and code quality checks pass."""
For code-change tasks, push the branch and notify the appropriate source once implementation is complete and code quality checks pass. Include the PR link when you opened or updated a PR; otherwise include the branch URL."""
COLLABORATION_TEMPLATE = """---
@ -377,11 +384,11 @@ def _render_collaboration_section(identity: CollaboratorIdentity | None) -> str:
)
NO_PR_OVERRIDE_SECTION = """---
ALWAYS_CREATE_PR_SECTION = """---
### Pull Request Policy Override
### Always Create PRs Policy Override
The user has disabled automatic PR creation. After implementation, **commit and push your branch** so the work is preserved, then notify the source channel with the branch URL (e.g. `https://github.com/<owner>/<repo>/tree/<branch>`) and a summary. **Do not** run `gh pr create` or `gh pr edit`. Ignore any instructions elsewhere in this prompt that tell you to open or update a draft pull request."""
The user's dashboard setting **Always Create PRs** is enabled. For code-change tasks, always open or update a draft pull request after committing and pushing the branch. This does not apply to questions, explanations, status checks, or other information-only requests where no files are changed."""
SYSTEM_PROMPT_TEMPLATE = (
@ -411,7 +418,7 @@ def construct_system_prompt(
linear_project_id: str = "",
linear_issue_number: str = "",
triggering_user_identity: CollaboratorIdentity | None = None,
create_prs: bool = True,
create_prs: bool = False,
) -> str:
default_prompt_section = _load_default_prompt()
return SYSTEM_PROMPT_TEMPLATE.format(
@ -419,6 +426,6 @@ def construct_system_prompt(
linear_project_id=linear_project_id or "<PROJECT_ID>",
linear_issue_number=linear_issue_number or "<ISSUE_NUMBER>",
default_prompt_section=default_prompt_section,
pr_policy_override_section="" if create_prs else NO_PR_OVERRIDE_SECTION,
pr_policy_override_section=ALWAYS_CREATE_PR_SECTION if create_prs else "",
collaboration_section=_render_collaboration_section(triggering_user_identity),
)

View file

@ -419,9 +419,9 @@ async def get_agent(config: RunnableConfig) -> Pregel:
model_id = per_thread_model
profile_effort = per_thread_effort
create_prs = profile_create_prs(profile)
if not create_prs:
logger.info("PR creation disabled by profile for %s", profile_login)
always_create_prs = profile_create_prs(profile)
if always_create_prs:
logger.info("Always Create PRs enabled by profile for %s", profile_login)
model_kwargs = provider_model_kwargs(
model_id,
@ -448,7 +448,7 @@ async def get_agent(config: RunnableConfig) -> Pregel:
linear_project_id=linear_project_id,
linear_issue_number=linear_issue_number,
triggering_user_identity=triggering_user_identity,
create_prs=create_prs,
create_prs=always_create_prs,
),
tools=[
http_request,

View file

@ -685,7 +685,7 @@ TRACE_REPLY_TIPS: tuple[str, ...] = (
"Kick off another task in parallel — each one runs in its own isolated sandbox, no queuing.",
"Add `repo:owner/name` to your message to point me at a different repo for this task.",
"Drop an `AGENTS.md` at your repo root and I'll read it on every run — it's the easiest way to teach me your conventions.",
"I'll open a draft PR automatically when I'm done and link it back here.",
"For code-change tasks, I'll open a draft PR when it's necessary or requested and link it back here.",
"Tag me on a PR comment of an open-swe PR to have me address review feedback on the same branch.",
"I can spawn subagents for independent subtasks — useful for parallel research or fan-out work.",
"Click `View trace` above to watch every tool call and model response live in LangSmith.",

View file

@ -1,6 +1,7 @@
from __future__ import annotations
from agent import webapp
from agent.dashboard.agent_overrides import profile_create_prs
from agent.prompt import construct_system_prompt
from agent.utils import github_comments
from agent.utils.authorship import CollaboratorIdentity
@ -46,6 +47,29 @@ def test_construct_system_prompt_omits_collaboration_section_without_identity()
assert "Co-authored-by:" not in prompt
def test_construct_system_prompt_does_not_require_pr_for_questions() -> None:
prompt = construct_system_prompt(working_dir="/workspace")
assert "Do not create commits, branches, or pull requests for questions" in prompt
assert "For information-only requests" in prompt
assert "open or update a draft PR when the user asks for one" in prompt
assert "Always Create PRs Policy Override" not in prompt
assert "Always push, open/update the draft PR" not in prompt
def test_construct_system_prompt_includes_always_create_prs_override() -> None:
prompt = construct_system_prompt(working_dir="/workspace", create_prs=True)
assert "Always Create PRs Policy Override" in prompt
assert "This does not apply to questions" in prompt
def test_profile_create_prs_defaults_to_normal_pr_policy() -> None:
assert profile_create_prs(None) is False
assert profile_create_prs({}) is False
assert profile_create_prs({"create_prs": True}) is True
def test_construct_system_prompt_includes_coauthor_trailer_when_identity_present() -> None:
identity = CollaboratorIdentity(
display_name="octocat",

View file

@ -1,52 +1,52 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { ApiError, api } from "./api";
import { useSession } from "./session";
import type {Profile, ProfileUpdate} from "./api";
import { ApiError, api } from "./api"
import { useSession } from "./session"
import type { Profile, ProfileUpdate } from "./api"
export function useProfile() {
const session = useSession();
const session = useSession()
return useQuery({
queryKey: ["profile"],
queryFn: api.profile,
enabled: !!session.data,
});
})
}
export function useOptions() {
const session = useSession();
const session = useSession()
return useQuery({
queryKey: ["options"],
queryFn: api.options,
enabled: !!session.data,
});
})
}
export function useRepos() {
const session = useSession();
const session = useSession()
return useQuery({
queryKey: ["repos"],
queryFn: async () => {
try {
return await api.repos();
return await api.repos()
} catch (e) {
if (e instanceof ApiError && e.status === 401)
return { installations: [], repositories: [] };
throw e;
return { installations: [], repositories: [] }
throw e
}
},
enabled: !!session.data,
});
})
}
export function useSaveProfile() {
const qc = useQueryClient();
const qc = useQueryClient()
return useMutation({
mutationFn: (body: ProfileUpdate) => api.saveProfile(body),
onSuccess: (saved) => {
qc.setQueryData(["profile"], saved);
qc.setQueryData(["profile"], saved)
},
});
})
}
/**
@ -58,7 +58,7 @@ export function buildProfileUpdate(
current: Profile | undefined,
patch: Partial<ProfileUpdate>,
fallbackModel: string,
fallbackEffort: string,
fallbackEffort: string
): ProfileUpdate {
return {
default_model: current?.default_model ?? fallbackModel,
@ -67,8 +67,8 @@ export function buildProfileUpdate(
base_branch: current?.base_branch ?? null,
branch_prefix: current?.branch_prefix ?? null,
auto_fix_ci: current?.auto_fix_ci ?? true,
create_prs: current?.create_prs ?? true,
create_prs: current?.create_prs ?? false,
review_draft_prs: current?.review_draft_prs ?? null,
...patch,
};
}
}

View file

@ -1,9 +1,9 @@
import { Navigate, createFileRoute } from "@tanstack/react-router";
import { useEffect, useRef, useState } from "react";
import { Navigate, createFileRoute } from "@tanstack/react-router"
import { useEffect, useRef, useState } from "react"
import type { ModelOption } from "@/lib/api";
import { AppShell, SettingsRow, SettingsSection } from "@/components/AppShell";
import { Button } from "@/components/ui/button";
import type { ModelOption } from "@/lib/api"
import { AppShell, SettingsRow, SettingsSection } from "@/components/AppShell"
import { Button } from "@/components/ui/button"
import {
Combobox,
ComboboxContent,
@ -11,81 +11,89 @@ import {
ComboboxInput,
ComboboxItem,
ComboboxList,
} from "@/components/ui/combobox";
import { Input } from "@/components/ui/input";
} from "@/components/ui/combobox"
import { Input } from "@/components/ui/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch";
import { buildProfileUpdate, useOptions, useProfile, useRepos, useSaveProfile } from "@/lib/profile";
import { useSession } from "@/lib/session";
} from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { Switch } from "@/components/ui/switch"
import {
buildProfileUpdate,
useOptions,
useProfile,
useRepos,
useSaveProfile,
} from "@/lib/profile"
import { useSession } from "@/lib/session"
export const Route = createFileRoute("/cloud-agents")({ component: CloudAgentsPage });
export const Route = createFileRoute("/cloud-agents")({
component: CloudAgentsPage,
})
function CloudAgentsPage() {
const session = useSession();
const profile = useProfile();
const options = useOptions();
const repos = useRepos();
const save = useSaveProfile();
const session = useSession()
const profile = useProfile()
const options = useOptions()
const repos = useRepos()
const save = useSaveProfile()
const [modelId, setModelId] = useState("");
const [effort, setEffort] = useState("");
const [defaultRepo, setDefaultRepo] = useState("");
const [baseBranch, setBaseBranch] = useState("");
const [branchPrefix, setBranchPrefix] = useState("");
const [error, setError] = useState<string | null>(null);
const initialized = useRef(false);
const [modelId, setModelId] = useState("")
const [effort, setEffort] = useState("")
const [defaultRepo, setDefaultRepo] = useState("")
const [baseBranch, setBaseBranch] = useState("")
const [branchPrefix, setBranchPrefix] = useState("")
const [error, setError] = useState<string | null>(null)
const initialized = useRef(false)
const firstModel: ModelOption | undefined = options.data?.models[0];
const firstModel: ModelOption | undefined = options.data?.models[0]
const currentModel: ModelOption | undefined =
options.data?.models.find((m) => m.id === modelId) ?? firstModel;
options.data?.models.find((m) => m.id === modelId) ?? firstModel
useEffect(() => {
if (!profile.data || initialized.current) return;
if (!profile.data || initialized.current) return
// For users with no saved profile, wait until the options API has loaded
// so the model/effort selects can initialise to the first available option.
const hasModel = !!profile.data.default_model || !!firstModel;
if (!hasModel) return;
initialized.current = true;
setModelId(profile.data.default_model ?? firstModel?.id ?? "");
setEffort(profile.data.reasoning_effort ?? firstModel?.default_effort ?? "");
setDefaultRepo(profile.data.default_repo ?? "");
setBaseBranch(profile.data.base_branch ?? "");
setBranchPrefix(profile.data.branch_prefix ?? "");
}, [profile.data, firstModel?.id, firstModel?.default_effort, firstModel]);
const hasModel = !!profile.data.default_model || !!firstModel
if (!hasModel) return
initialized.current = true
setModelId(profile.data.default_model ?? firstModel?.id ?? "")
setEffort(profile.data.reasoning_effort ?? firstModel?.default_effort ?? "")
setDefaultRepo(profile.data.default_repo ?? "")
setBaseBranch(profile.data.base_branch ?? "")
setBranchPrefix(profile.data.branch_prefix ?? "")
}, [profile.data, firstModel?.id, firstModel?.default_effort, firstModel])
useEffect(() => {
if (currentModel && !currentModel.efforts.includes(effort)) {
setEffort(currentModel.default_effort);
setEffort(currentModel.default_effort)
}
}, [currentModel, effort]);
}, [currentModel, effort])
if (session.isLoading) {
return (
<main className="p-6">
<Skeleton className="h-64 w-full" />
</main>
);
)
}
if (!session.data) return <Navigate to="/login" />;
if (!session.data) return <Navigate to="/login" />
const fallbackModel = firstModel?.id ?? "";
const fallbackEffort = firstModel?.default_effort ?? "";
const fallbackModel = firstModel?.id ?? ""
const fallbackEffort = firstModel?.default_effort ?? ""
const persist = (patch: Parameters<typeof buildProfileUpdate>[1]) => {
setError(null);
setError(null)
save
.mutateAsync(
buildProfileUpdate(profile.data, patch, fallbackModel, fallbackEffort),
buildProfileUpdate(profile.data, patch, fallbackModel, fallbackEffort)
)
.catch((e: Error) => setError(e.message));
};
.catch((e: Error) => setError(e.message))
}
const persistDefaults = () => {
persist({
@ -94,8 +102,8 @@ function CloudAgentsPage() {
default_repo: defaultRepo || null,
base_branch: baseBranch || null,
branch_prefix: branchPrefix || null,
});
};
})
}
return (
<AppShell
@ -154,7 +162,11 @@ function CloudAgentsPage() {
setDefaultRepo(typeof v === "string" ? v : "")
}
>
<ComboboxInput placeholder="Pick a repository…" showClear className="w-full" />
<ComboboxInput
placeholder="Pick a repository…"
showClear
className="w-full"
/>
<ComboboxContent className="min-w-[var(--anchor-width)]">
<ComboboxList className="max-h-64">
<ComboboxEmpty>No matches</ComboboxEmpty>
@ -205,7 +217,11 @@ function CloudAgentsPage() {
}
/>
<div className="flex justify-end px-4 py-3">
<Button size="sm" onClick={persistDefaults} disabled={save.isPending}>
<Button
size="sm"
onClick={persistDefaults}
disabled={save.isPending}
>
{save.isPending ? "Saving…" : "Save defaults"}
</Button>
</div>
@ -227,11 +243,11 @@ function CloudAgentsPage() {
}
/>
<SettingsRow
label="Create PRs"
description="Automatically create a pull request when a Cloud Agent completes. When disabled, the branch is still pushed."
label="Always Create PRs"
description="Always create a pull request for code changes. When disabled, agents create PRs only when necessary or requested."
control={
<Switch
checked={profile.data?.create_prs ?? true}
checked={profile.data?.create_prs ?? false}
onCheckedChange={(v) => persist({ create_prs: v })}
/>
}
@ -241,5 +257,5 @@ function CloudAgentsPage() {
{error && <p className="text-xs text-destructive">{error}</p>}
</AppShell>
);
)
}