feat: per-repo custom instructions for the coding agent (#1460)

* feat: per-repo custom instructions for the coding agent

Adds per-repository custom instructions for the main coding agent,
mirroring the reviewer's per-repo style prompts. Instructions are stored
in the LangGraph Store, managed via dashboard API + UI (Monaco editor),
and appended to the agent's system prompt for runs targeting that repo.

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>

* chore: wire agent instructions route into generated route tree

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>

* fix: enforce repo access on instruction routes

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>

---------

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
This commit is contained in:
Johannes du Plessis 2026-06-09 15:46:29 -07:00 • committed by GitHub
parent d9f673f5a5
commit da8f003933
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 847 additions and 5 deletions

View file

@ -0,0 +1,113 @@
"""Per-repository custom instructions for the main coding agent.
Each record holds a user-authored instruction prompt (edited in the dashboard)
that is appended to the main agent's system prompt for runs targeting that repo.
"""
from __future__ import annotations
import logging
from datetime import UTC, datetime
from typing import Any
from langgraph_sdk import get_client
from pydantic import BaseModel, Field, field_validator
from .review_styles import normalize_repo_full_name
logger = logging.getLogger(__name__)
AGENT_INSTRUCTIONS_NAMESPACE: list[str] = ["agent_instructions"]
class AgentInstructionsCreate(BaseModel):
full_name: str = Field(..., description="GitHub repo in owner/name form")
@field_validator("full_name", mode="before")
@classmethod
def _valid_full_name(cls, v: str) -> str:
return normalize_repo_full_name(v)
class AgentInstructionsUpdate(BaseModel):
instructions: str = Field(default="")
def _client():
return get_client()
async def _get_value(key: str) -> dict[str, Any] | None:
try:
item = await _client().store.get_item(AGENT_INSTRUCTIONS_NAMESPACE, key)
except Exception as e: # noqa: BLE001
logger.debug("store get_item failed for %s: %s", key, e)
return None
if item is None:
return None
value = item.get("value") if isinstance(item, dict) else getattr(item, "value", None)
return value if isinstance(value, dict) else None
def _now_iso() -> str:
return datetime.now(UTC).isoformat()
def _default_record(full_name: str, created_by: str) -> dict[str, Any]:
owner, name = full_name.split("/", 1)
return {
"full_name": full_name,
"owner": owner,
"name": name,
"instructions": "",
"created_by": created_by,
"created_at": _now_iso(),
"updated_at": _now_iso(),
}
async def get_agent_instructions(full_name: str) -> dict[str, Any] | None:
return await _get_value(full_name)
async def list_agent_instructions() -> list[dict[str, Any]]:
result = await _client().store.search_items(AGENT_INSTRUCTIONS_NAMESPACE, limit=1000)
items = result.get("items") if isinstance(result, dict) else getattr(result, "items", [])
out: list[dict[str, Any]] = []
for item in items or []:
value = item.get("value") if isinstance(item, dict) else getattr(item, "value", None)
if isinstance(value, dict):
out.append(value)
out.sort(key=lambda r: r.get("full_name", ""))
return out
async def create_agent_instructions(full_name: str, created_by: str) -> dict[str, Any]:
existing = await get_agent_instructions(full_name)
if existing:
return existing
value = _default_record(full_name, created_by)
await _client().store.put_item(AGENT_INSTRUCTIONS_NAMESPACE, full_name, value)
return value
async def set_agent_instructions(full_name: str, instructions: str) -> dict[str, Any]:
existing = await get_agent_instructions(full_name) or _default_record(full_name, "")
value = {**existing, "instructions": instructions, "updated_at": _now_iso()}
await _client().store.put_item(AGENT_INSTRUCTIONS_NAMESPACE, full_name, value)
return value
async def delete_agent_instructions(full_name: str) -> None:
await _client().store.delete_item(AGENT_INSTRUCTIONS_NAMESPACE, full_name)
async def get_repo_agent_instructions(owner: str, repo: str) -> str | None:
"""Return the custom agent instructions for a repo, if configured."""
record = await get_agent_instructions(f"{owner}/{repo}")
if not record:
return None
instructions = record.get("instructions")
if isinstance(instructions, str) and instructions.strip():
return instructions.strip()
return None

View file

@ -13,6 +13,15 @@ from fastapi.responses import RedirectResponse, Response, StreamingResponse
from pydantic import BaseModel
from .admin import is_admin
from .agent_instructions import (
AgentInstructionsCreate,
AgentInstructionsUpdate,
create_agent_instructions,
delete_agent_instructions,
get_agent_instructions,
list_agent_instructions,
set_agent_instructions,
)
from .agent_usage import (
list_agent_usage_leaderboard,
refresh_reviewer_stats_cache,
@ -127,6 +136,25 @@ def _admin_session(session: dict[str, Any] = _SESSION_DEP) -> dict[str, Any]:
_ADMIN_DEP = Depends(_admin_session)
async def _filter_repo_records_for_user(
login: str,
records: list[dict[str, Any]],
) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
for record in records:
full_name = record.get("full_name")
if not isinstance(full_name, str):
continue
try:
await require_repo_access_for_user(login, full_name)
except HTTPException as exc:
if exc.status_code in {403, 404}:
continue
raise
out.append(record)
return out
def _api_base_url() -> str:
v = os.environ.get("DASHBOARD_API_BASE_URL", "").rstrip("/")
if not v:
@ -609,7 +637,7 @@ async def list_repos(
async def api_list_review_styles(
session: dict[str, Any] = _SESSION_DEP,
) -> list[dict[str, Any]]:
records = await list_review_styles()
records = await _filter_repo_records_for_user(session["sub"], await list_review_styles())
out: list[dict[str, Any]] = []
for record in records:
if record.get("status") == "running":
@ -635,6 +663,7 @@ async def api_get_review_style(
session: dict[str, Any] = _SESSION_DEP,
) -> dict[str, Any]:
full_name = normalize_repo_full_name(full_name)
await require_repo_access_for_user(session["sub"], full_name)
record = await get_review_style(full_name)
if not record:
raise HTTPException(404, "review style not found")
@ -650,10 +679,10 @@ async def api_update_review_style_prompt(
session: dict[str, Any] = _SESSION_DEP,
) -> dict[str, Any]:
full_name = normalize_repo_full_name(full_name)
await require_repo_access_for_user(session["sub"], full_name)
record = await get_review_style(full_name)
if not record:
raise HTTPException(404, "review style not found")
await require_repo_access_for_user(session["sub"], full_name)
return await set_custom_prompt(full_name, body.custom_prompt)
@ -683,8 +712,8 @@ async def api_cancel_review_style(
full_name: str,
session: dict[str, Any] = _SESSION_DEP,
) -> dict[str, Any]:
del session
full_name = normalize_repo_full_name(full_name)
await require_repo_access_for_user(session["sub"], full_name)
record = await get_review_style(full_name)
if not record:
raise HTTPException(404, "review style not found")
@ -696,8 +725,8 @@ async def api_delete_review_style(
full_name: str,
session: dict[str, Any] = _SESSION_DEP,
) -> Response:
del session
full_name = normalize_repo_full_name(full_name)
await require_repo_access_for_user(session["sub"], full_name)
record = await get_review_style(full_name)
if not record:
raise HTTPException(404, "review style not found")
@ -708,6 +737,60 @@ async def api_delete_review_style(
return Response(status_code=204)
@router.get("/agent-instructions")
async def api_list_agent_instructions(
session: dict[str, Any] = _SESSION_DEP,
) -> list[dict[str, Any]]:
return await _filter_repo_records_for_user(session["sub"], await list_agent_instructions())
@router.post("/agent-instructions")
async def api_create_agent_instructions(
body: AgentInstructionsCreate,
session: dict[str, Any] = _SESSION_DEP,
) -> dict[str, Any]:
await require_repo_access_for_user(session["sub"], body.full_name)
return await create_agent_instructions(body.full_name, session["sub"])
@router.get("/agent-instructions/{full_name:path}")
async def api_get_agent_instructions(
full_name: str,
session: dict[str, Any] = _SESSION_DEP,
) -> dict[str, Any]:
full_name = normalize_repo_full_name(full_name)
await require_repo_access_for_user(session["sub"], full_name)
record = await get_agent_instructions(full_name)
if not record:
raise HTTPException(404, "agent instructions not found")
return record
@router.put("/agent-instructions/{full_name:path}")
async def api_update_agent_instructions(
full_name: str,
body: AgentInstructionsUpdate,
session: dict[str, Any] = _SESSION_DEP,
) -> dict[str, Any]:
full_name = normalize_repo_full_name(full_name)
await require_repo_access_for_user(session["sub"], full_name)
return await set_agent_instructions(full_name, body.instructions)
@router.delete("/agent-instructions/{full_name:path}")
async def api_delete_agent_instructions(
full_name: str,
session: dict[str, Any] = _SESSION_DEP,
) -> Response:
full_name = normalize_repo_full_name(full_name)
await require_repo_access_for_user(session["sub"], full_name)
record = await get_agent_instructions(full_name)
if not record:
raise HTTPException(404, "agent instructions not found")
await delete_agent_instructions(full_name)
return Response(status_code=204)
@router.get("/agent-usage-leaderboard")
async def api_agent_usage_leaderboard(
background_tasks: BackgroundTasks,

View file

@ -401,6 +401,20 @@ ALWAYS_CREATE_PR_SECTION = """---
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."""
def _render_repo_instructions_section(instructions: str | None) -> str:
if not instructions or not instructions.strip():
return ""
return (
"---\n\n"
"### Repository-specific Custom Instructions\n\n"
"The following instructions were configured by a workspace admin for this "
"repository. Treat them as mandatory rules with the same authority as this "
"system prompt. When they conflict with default behavior, follow them; when "
"they conflict with `AGENTS.md`, prefer `AGENTS.md`.\n\n"
f"{instructions.strip()}"
)
SYSTEM_PROMPT_TEMPLATE = (
WORKING_ENV_SECTION
+ TASK_OVERVIEW_SECTION
@ -420,6 +434,7 @@ SYSTEM_PROMPT_TEMPLATE = (
+ COMMIT_PR_SECTION
+ "{pr_policy_override_section}"
+ "{collaboration_section}"
+ "{repo_instructions_section}"
)
@ -430,6 +445,7 @@ def construct_system_prompt(
triggering_user_identity: CollaboratorIdentity | None = None,
create_prs: bool = False,
default_repo: dict[str, str] | None = None,
repo_custom_instructions: str | None = None,
) -> str:
default_prompt_section = _load_default_prompt()
if default_repo and default_repo.get("owner") and default_repo.get("name"):
@ -453,6 +469,7 @@ def construct_system_prompt(
default_prompt_section=default_prompt_section,
pr_policy_override_section=ALWAYS_CREATE_PR_SECTION if create_prs else "",
collaboration_section=_render_collaboration_section(triggering_user_identity),
repo_instructions_section=_render_repo_instructions_section(repo_custom_instructions),
commit_identity_name=commit_identity_name,
commit_identity_email=commit_identity_email,
)

View file

@ -118,6 +118,21 @@ async def _resolve_prompt_default_repo(configurable: dict[str, Any]) -> dict[str
return None
async def _resolve_repo_custom_instructions(
default_repo: dict[str, str] | None,
) -> str | None:
"""Load per-repo custom agent instructions for the resolved default repo."""
if not default_repo or not default_repo.get("owner") or not default_repo.get("name"):
return None
try:
from .dashboard.agent_instructions import get_repo_agent_instructions
return await get_repo_agent_instructions(default_repo["owner"], default_repo["name"])
except Exception:
logger.debug("Failed to load repo custom agent instructions", exc_info=True)
return None
async def _start_langsmith_sandbox_if_needed(sandbox_backend: SandboxBackendProtocol) -> None:
"""Start a LangSmith sandbox before operations that require it to be running."""
if os.getenv("SANDBOX_TYPE", "langsmith") != "langsmith":
@ -560,6 +575,7 @@ async def get_agent(config: RunnableConfig) -> Pregel:
logger.debug("Failed to record agent usage for thread %s", thread_id, exc_info=True)
prompt_default_repo = await _resolve_prompt_default_repo(configurable)
repo_custom_instructions = await _resolve_repo_custom_instructions(prompt_default_repo)
logger.info("Returning agent with sandbox for thread %s", thread_id)
main_model = make_model(model_id, **model_kwargs)
@ -573,6 +589,7 @@ async def get_agent(config: RunnableConfig) -> Pregel:
triggering_user_identity=triggering_user_identity,
create_prs=always_create_prs,
default_repo=prompt_default_repo,
repo_custom_instructions=repo_custom_instructions,
),
tools=[
http_request,

View file

@ -0,0 +1,155 @@
from __future__ import annotations
import asyncio
from unittest.mock import AsyncMock, patch
import pytest
from fastapi import HTTPException
from agent import server
from agent.dashboard import routes
from agent.dashboard.agent_instructions import (
create_agent_instructions,
get_repo_agent_instructions,
set_agent_instructions,
)
from agent.prompt import construct_system_prompt
@pytest.mark.asyncio
async def test_get_repo_agent_instructions_returns_trimmed_text() -> None:
with patch(
"agent.dashboard.agent_instructions.get_agent_instructions",
new_callable=AsyncMock,
return_value={"instructions": " Always run mypy.\n"},
):
result = await get_repo_agent_instructions("acme", "repo")
assert result == "Always run mypy."
@pytest.mark.asyncio
async def test_get_repo_agent_instructions_returns_none_when_empty() -> None:
with patch(
"agent.dashboard.agent_instructions.get_agent_instructions",
new_callable=AsyncMock,
return_value={"instructions": " "},
):
result = await get_repo_agent_instructions("acme", "repo")
assert result is None
@pytest.mark.asyncio
async def test_create_agent_instructions_puts_new_record() -> None:
mock_put = AsyncMock()
with (
patch(
"agent.dashboard.agent_instructions._get_value",
new_callable=AsyncMock,
return_value=None,
),
patch("agent.dashboard.agent_instructions._client") as mock_client,
):
mock_client.return_value.store.put_item = mock_put
record = await create_agent_instructions("acme/repo", "octo")
assert record["full_name"] == "acme/repo"
assert record["instructions"] == ""
mock_put.assert_awaited_once()
@pytest.mark.asyncio
async def test_set_agent_instructions_updates_store() -> None:
mock_put = AsyncMock()
with (
patch(
"agent.dashboard.agent_instructions.get_agent_instructions",
new_callable=AsyncMock,
return_value={"full_name": "acme/repo", "instructions": ""},
),
patch("agent.dashboard.agent_instructions._client") as mock_client,
):
mock_client.return_value.store.put_item = mock_put
record = await set_agent_instructions("acme/repo", "Use direct tone.")
assert record["instructions"] == "Use direct tone."
mock_put.assert_awaited_once()
def test_construct_system_prompt_appends_repo_instructions() -> None:
prompt = construct_system_prompt(
working_dir="/work",
repo_custom_instructions="Prefer pytest over unittest.",
)
assert "Repository-specific Custom Instructions" in prompt
assert "Prefer pytest over unittest." in prompt
def test_construct_system_prompt_without_repo_instructions() -> None:
prompt = construct_system_prompt(working_dir="/work")
assert "Repository-specific Custom Instructions" not in prompt
def test_resolve_repo_custom_instructions_returns_none_without_repo() -> None:
result = asyncio.run(server._resolve_repo_custom_instructions(None))
assert result is None
@pytest.mark.asyncio
async def test_list_agent_instructions_filters_inaccessible_repos(monkeypatch) -> None:
monkeypatch.setattr(
routes,
"list_agent_instructions",
AsyncMock(
return_value=[
{"full_name": "acme/visible", "instructions": "visible"},
{"full_name": "acme/private", "instructions": "private"},
]
),
)
async def fake_require_repo_access_for_user(login: str, full_name: str) -> str:
if full_name == "acme/private":
raise HTTPException(403, "no access")
return "token"
monkeypatch.setattr(routes, "require_repo_access_for_user", fake_require_repo_access_for_user)
result = await routes.api_list_agent_instructions(session={"sub": "octocat"})
assert result == [{"full_name": "acme/visible", "instructions": "visible"}]
@pytest.mark.asyncio
async def test_get_agent_instructions_requires_repo_access(monkeypatch) -> None:
require_access = AsyncMock(return_value="token")
monkeypatch.setattr(
routes,
"get_agent_instructions",
AsyncMock(return_value={"full_name": "acme/repo", "instructions": "rules"}),
)
monkeypatch.setattr(routes, "require_repo_access_for_user", require_access)
result = await routes.api_get_agent_instructions(
"https://github.com/acme/repo", session={"sub": "octocat"}
)
assert result == {"full_name": "acme/repo", "instructions": "rules"}
require_access.assert_awaited_once_with("octocat", "acme/repo")
@pytest.mark.asyncio
async def test_delete_agent_instructions_requires_repo_access_before_delete(monkeypatch) -> None:
delete_instructions = AsyncMock()
get_instructions = AsyncMock(return_value={"full_name": "acme/repo", "instructions": "rules"})
monkeypatch.setattr(routes, "get_agent_instructions", get_instructions)
monkeypatch.setattr(
routes,
"require_repo_access_for_user",
AsyncMock(side_effect=HTTPException(403, "no access")),
)
monkeypatch.setattr(routes, "delete_agent_instructions", delete_instructions)
with pytest.raises(HTTPException) as exc:
await routes.api_delete_agent_instructions("acme/repo", session={"sub": "octocat"})
assert exc.value.status_code == 403
get_instructions.assert_not_awaited()
delete_instructions.assert_not_awaited()

View file

@ -14,6 +14,7 @@
"dependencies": {
"@base-ui/react": "^1.4.1",
"@fontsource-variable/inter": "^5.2.8",
"@monaco-editor/react": "^4.7.0",
"@phosphor-icons/react": "^2.1.10",
"@pierre/diffs": "^1.2.1",
"@tailwindcss/vite": "^4.2.1",
@ -29,6 +30,7 @@
"clsx": "^2.1.1",
"diff": "^9.0.0",
"lucide-react": "^1.16.0",
"monaco-editor": "^0.52.2",
"nitro": "latest",
"react": "^19.2.4",
"react-dom": "^19.2.4",

View file

@ -0,0 +1,272 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
} from "@/components/ui/combobox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Skeleton } from "@/components/ui/skeleton";
import { InstructionsEditor } from "@/components/InstructionsEditor";
import { ApiError, api, isGithubReauthError, loginUrl } from "@/lib/api";
import { normalizeRepoFullName } from "@/lib/repo";
function formatMutationError(e: Error): string {
return isGithubReauthError(e)
? "GitHub token expired — sign in again using the link above."
: e.message;
}
export function AgentInstructionsPanel() {
const qc = useQueryClient();
const [error, setError] = useState<string | null>(null);
const [addRepo, setAddRepo] = useState("");
const [selected, setSelected] = useState<string | null>(null);
const [draft, setDraft] = useState("");
const instructions = useQuery({
queryKey: ["agentInstructions"],
queryFn: api.listAgentInstructions,
});
const repos = useQuery({
queryKey: ["repos"],
queryFn: async () => {
try {
return await api.repos();
} catch (e) {
if (e instanceof ApiError && e.status === 401)
return { installations: [], repositories: [] };
throw e;
}
},
});
const detail = useQuery({
queryKey: ["agentInstruction", selected],
queryFn: () => api.getAgentInstructions(selected!),
enabled: !!selected,
});
useEffect(() => {
if (detail.data) setDraft(detail.data.instructions ?? "");
}, [detail.data?.instructions, detail.data?.full_name]);
const create = useMutation({
mutationFn: (full_name: string) => api.createAgentInstructions(full_name),
onSuccess: (record) => {
void qc.invalidateQueries({ queryKey: ["agentInstructions"] });
setSelected(record.full_name);
setError(null);
},
onError: (e: Error) => setError(formatMutationError(e)),
});
const save = useMutation({
mutationFn: ({ full_name, value }: { full_name: string; value: string }) =>
api.saveAgentInstructions(full_name, value),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ["agentInstructions"] });
void qc.invalidateQueries({ queryKey: ["agentInstruction", selected] });
setError(null);
},
onError: (e: Error) => setError(formatMutationError(e)),
});
const remove = useMutation({
mutationFn: (full_name: string) => api.deleteAgentInstructions(full_name),
onSuccess: (_data, full_name) => {
void qc.invalidateQueries({ queryKey: ["agentInstructions"] });
if (selected === full_name) {
setSelected(null);
setDraft("");
}
setError(null);
},
onError: (e: Error) => setError(formatMutationError(e)),
});
if (instructions.isLoading) {
return <Skeleton className="h-40" />;
}
const configured = new Set((instructions.data ?? []).map((s) => s.full_name));
const suggestedRepos = (repos.data?.repositories ?? []).filter(
(r) => !configured.has(r.full_name),
);
const normalizedAddRepo = normalizeRepoFullName(addRepo);
const canAdd = normalizedAddRepo !== null && !configured.has(normalizedAddRepo);
const active =
detail.data ?? instructions.data?.find((s) => s.full_name === selected) ?? null;
const dirty = active != null && draft !== (active.instructions ?? "");
const handleAdd = () => {
if (!normalizedAddRepo || !canAdd) return;
void create
.mutateAsync(normalizedAddRepo)
.then(() => setAddRepo(""))
.catch(() => undefined);
};
const githubReauth =
(repos.isError && isGithubReauthError(repos.error)) ||
(error !== null && /github token|re-login required/i.test(error));
return (
<div className="flex flex-col gap-6 p-4">
{githubReauth && (
<div className="rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-xs text-destructive">
Your GitHub connection expired.{" "}
<a href={loginUrl()} className="font-medium underline underline-offset-2">
Sign in with GitHub again
</a>{" "}
to list installed repos.
</div>
)}
<section className="space-y-4">
<div className="space-y-2">
<Label htmlFor="add-instruction-repo">Add repository</Label>
<div className="flex flex-col gap-2 sm:flex-row sm:items-end">
<Input
id="add-instruction-repo"
placeholder="owner/repo"
value={addRepo}
onChange={(e) => setAddRepo(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleAdd();
}
}}
className="sm:flex-1"
/>
<Button
size="sm"
className="shrink-0 sm:w-auto"
disabled={!canAdd || create.isPending}
onClick={handleAdd}
>
Add
</Button>
</div>
{suggestedRepos.length > 0 && (
<Combobox
items={suggestedRepos.map((r) => r.full_name)}
value={addRepo}
onValueChange={(v) => setAddRepo(typeof v === "string" ? v : "")}
>
<ComboboxInput
placeholder="Search installed repos…"
showClear
className="w-full"
/>
<ComboboxContent className="min-w-[var(--anchor-width)]">
<ComboboxList className="max-h-48">
<ComboboxEmpty>No matches</ComboboxEmpty>
{suggestedRepos.map((r) => (
<ComboboxItem key={r.full_name} value={r.full_name}>
<span className="truncate">{r.full_name}</span>
{r.private && (
<span className="ml-auto text-[10px] text-muted-foreground">
private
</span>
)}
</ComboboxItem>
))}
</ComboboxList>
</ComboboxContent>
</Combobox>
)}
</div>
<div className="space-y-2">
<p className="text-xs font-medium text-foreground">Repositories</p>
{(instructions.data ?? []).length === 0 ? (
<p className="text-xs text-muted-foreground">No repositories yet.</p>
) : (
<ul className="flex flex-wrap gap-2">
{(instructions.data ?? []).map((s) => (
<li key={s.full_name}>
<button
type="button"
className={`inline-flex max-w-full items-center gap-2 rounded-md border px-2.5 py-1.5 text-left text-xs transition-colors hover:bg-muted ${
selected === s.full_name
? "border-primary bg-muted font-medium"
: "border-border"
}`}
onClick={() => setSelected(s.full_name)}
>
<span className="truncate">{s.full_name}</span>
</button>
</li>
))}
</ul>
)}
</div>
</section>
<div className="border-t border-border" />
<section className="space-y-3">
{!selected || !active ? (
<p className="text-xs text-muted-foreground">
Select a repository above to view or edit its custom agent instructions.
</p>
) : (
<>
<p className="text-sm font-medium text-foreground">{active.full_name}</p>
<div className="flex flex-wrap gap-2">
<Button
size="sm"
disabled={!dirty || save.isPending}
onClick={() =>
void save.mutateAsync({
full_name: active.full_name,
value: draft,
})
}
>
Save instructions
</Button>
{dirty && (
<span className="self-center text-xs text-muted-foreground">
Unsaved changes
</span>
)}
<Button
size="sm"
variant="destructive"
className="ml-auto"
disabled={remove.isPending}
onClick={() => {
if (
!window.confirm(
`Remove custom instructions for ${active.full_name}? This cannot be undone.`,
)
) {
return;
}
void remove.mutateAsync(active.full_name);
}}
>
Remove
</Button>
</div>
<InstructionsEditor
value={draft}
onChange={setDraft}
placeholder="Write custom instructions for the coding agent on this repository (markdown)."
/>
</>
)}
{error && <p className="text-xs text-destructive">{error}</p>}
</section>
</div>
);
}

View file

@ -0,0 +1,59 @@
import Editor from "@monaco-editor/react";
import { useEffect, useState } from "react";
import { Textarea } from "@/components/ui/textarea";
interface InstructionsEditorProps {
value: string;
onChange: (value: string) => void;
disabled?: boolean;
placeholder?: string;
}
/** Monaco-backed markdown editor that falls back to a textarea before mount (SSR-safe). */
export function InstructionsEditor({
value,
onChange,
disabled,
placeholder,
}: InstructionsEditorProps) {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return (
<Textarea
className="min-h-[360px] w-full font-mono text-xs"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
disabled={disabled}
/>
);
}
return (
<div className="overflow-hidden rounded-md border border-border">
<Editor
height="360px"
defaultLanguage="markdown"
value={value}
onChange={(v) => onChange(v ?? "")}
options={{
readOnly: disabled,
minimap: { enabled: false },
wordWrap: "on",
fontSize: 12,
lineNumbers: "on",
scrollBeyondLastLine: false,
padding: { top: 12, bottom: 12 },
renderLineHighlight: "none",
}}
theme="vs-dark"
/>
</div>
);
}

View file

@ -225,6 +225,16 @@ export interface ReviewStyle {
updated_at?: string;
}
export interface AgentInstructions {
full_name: string;
owner?: string;
name?: string;
instructions: string;
created_by?: string;
created_at?: string;
updated_at?: string;
}
export const api = {
me: () => request<SessionUser>("/me"),
options: () => request<OptionsPayload>("/options"),
@ -257,6 +267,23 @@ export const api = {
request<void>(`/review-styles/${encodeURIComponent(full_name)}`, {
method: "DELETE",
}),
listAgentInstructions: () => request<Array<AgentInstructions>>("/agent-instructions"),
createAgentInstructions: (full_name: string) =>
request<AgentInstructions>("/agent-instructions", {
method: "POST",
body: JSON.stringify({ full_name }),
}),
getAgentInstructions: (full_name: string) =>
request<AgentInstructions>(`/agent-instructions/${encodeURIComponent(full_name)}`),
saveAgentInstructions: (full_name: string, instructions: string) =>
request<AgentInstructions>(`/agent-instructions/${encodeURIComponent(full_name)}`, {
method: "PUT",
body: JSON.stringify({ instructions }),
}),
deleteAgentInstructions: (full_name: string) =>
request<void>(`/agent-instructions/${encodeURIComponent(full_name)}`, {
method: "DELETE",
}),
getTeamSettings: () => request<TeamSettings>("/team-settings"),
saveTeamSettings: (body: TeamSettings) =>
request<TeamSettings>("/team-settings", { method: "PUT", body: JSON.stringify(body) }),

View file

@ -20,6 +20,7 @@ import { Route as AdminRouteImport } from './routes/admin'
import { Route as IndexRouteImport } from './routes/index'
import { Route as AgentsIndexRouteImport } from './routes/agents/index'
import { Route as ReviewStylesRouteImport } from './routes/review_.styles'
import { Route as AgentsInstructionsRouteImport } from './routes/agents_.instructions'
import { Route as AgentsThreadIdRouteImport } from './routes/agents/$threadId'
import { Route as AgentsAutomationsIndexRouteImport } from './routes/agents/automations/index'
import { Route as ReviewRepositoriesOwnerRouteImport } from './routes/review_.repositories.$owner'
@ -81,6 +82,11 @@ const ReviewStylesRoute = ReviewStylesRouteImport.update({
path: '/review/styles',
getParentRoute: () => rootRouteImport,
} as any)
const AgentsInstructionsRoute = AgentsInstructionsRouteImport.update({
id: '/agents_/instructions',
path: '/agents/instructions',
getParentRoute: () => rootRouteImport,
} as any)
const AgentsThreadIdRoute = AgentsThreadIdRouteImport.update({
id: '/$threadId',
path: '/$threadId',
@ -120,6 +126,7 @@ export interface FileRoutesByFullPath {
'/usage': typeof UsageRoute
'/agents/$threadId': typeof AgentsThreadIdRoute
'/review/styles': typeof ReviewStylesRoute
'/agents/instructions': typeof AgentsInstructionsRoute
'/agents/': typeof AgentsIndexRoute
'/agents/automations/$scheduleId': typeof AgentsAutomationsScheduleIdRoute
'/agents/automations/new': typeof AgentsAutomationsNewRoute
@ -137,6 +144,7 @@ export interface FileRoutesByTo {
'/usage': typeof UsageRoute
'/agents/$threadId': typeof AgentsThreadIdRoute
'/review/styles': typeof ReviewStylesRoute
'/agents/instructions': typeof AgentsInstructionsRoute
'/agents': typeof AgentsIndexRoute
'/agents/automations/$scheduleId': typeof AgentsAutomationsScheduleIdRoute
'/agents/automations/new': typeof AgentsAutomationsNewRoute
@ -156,6 +164,7 @@ export interface FileRoutesById {
'/usage': typeof UsageRoute
'/agents/$threadId': typeof AgentsThreadIdRoute
'/review_/styles': typeof ReviewStylesRoute
'/agents_/instructions': typeof AgentsInstructionsRoute
'/agents/': typeof AgentsIndexRoute
'/agents/automations/$scheduleId': typeof AgentsAutomationsScheduleIdRoute
'/agents/automations/new': typeof AgentsAutomationsNewRoute
@ -176,6 +185,7 @@ export interface FileRouteTypes {
| '/usage'
| '/agents/$threadId'
| '/review/styles'
| '/agents/instructions'
| '/agents/'
| '/agents/automations/$scheduleId'
| '/agents/automations/new'
@ -193,6 +203,7 @@ export interface FileRouteTypes {
| '/usage'
| '/agents/$threadId'
| '/review/styles'
| '/agents/instructions'
| '/agents'
| '/agents/automations/$scheduleId'
| '/agents/automations/new'
@ -211,6 +222,7 @@ export interface FileRouteTypes {
| '/usage'
| '/agents/$threadId'
| '/review_/styles'
| '/agents_/instructions'
| '/agents/'
| '/agents/automations/$scheduleId'
| '/agents/automations/new'
@ -229,6 +241,7 @@ export interface RootRouteChildren {
ReviewRoute: typeof ReviewRoute
UsageRoute: typeof UsageRoute
ReviewStylesRoute: typeof ReviewStylesRoute
AgentsInstructionsRoute: typeof AgentsInstructionsRoute
ReviewRepositoriesOwnerRoute: typeof ReviewRepositoriesOwnerRoute
}
@ -311,6 +324,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ReviewStylesRouteImport
parentRoute: typeof rootRouteImport
}
'/agents_/instructions': {
id: '/agents_/instructions'
path: '/agents/instructions'
fullPath: '/agents/instructions'
preLoaderRoute: typeof AgentsInstructionsRouteImport
parentRoute: typeof rootRouteImport
}
'/agents/$threadId': {
id: '/agents/$threadId'
path: '/$threadId'
@ -379,6 +399,7 @@ const rootRouteChildren: RootRouteChildren = {
ReviewRoute: ReviewRoute,
UsageRoute: UsageRoute,
ReviewStylesRoute: ReviewStylesRoute,
AgentsInstructionsRoute: AgentsInstructionsRoute,
ReviewRepositoriesOwnerRoute: ReviewRepositoriesOwnerRoute,
}
export const routeTree = rootRouteImport

View file

@ -0,0 +1,36 @@
import { Navigate, createFileRoute } from "@tanstack/react-router";
import { AgentInstructionsPanel } from "@/components/AgentInstructionsPanel";
import { AppShell } from "@/components/AppShell";
import { Skeleton } from "@/components/ui/skeleton";
import { useSession } from "@/lib/session";
export const Route = createFileRoute("/agents_/instructions")({
component: AgentInstructionsPage,
});
function AgentInstructionsPage() {
const session = useSession();
if (session.isLoading) {
return (
<main className="p-6">
<Skeleton className="h-64 w-full" />
</main>
);
}
if (!session.data) return <Navigate to="/login" />;
return (
<AppShell
user={session.data}
title="Repository Instructions"
description="Per-repo custom instructions appended to the coding agent's system prompt for runs targeting that repository."
backTo={{ to: "/cloud-agents", label: "Back to Open SWE Agent" }}
>
<div className="rounded-lg border border-border bg-card">
<AgentInstructionsPanel />
</div>
</AppShell>
);
}

View file

@ -1,4 +1,5 @@
import { Navigate, createFileRoute } from "@tanstack/react-router"
import { Link, Navigate, createFileRoute } from "@tanstack/react-router"
import { CaretRightIcon } from "@phosphor-icons/react"
import { useEffect, useRef, useState } from "react"
import type { ModelOption } from "@/lib/api"
@ -316,6 +317,21 @@ function CloudAgentsPage() {
</div>
</SettingsSection>
<SettingsSection title="Rules">
<Link
to="/agents/instructions"
className="flex items-center justify-between gap-6 px-4 py-3 hover:bg-muted/40"
>
<div className="flex flex-col gap-0.5">
<span className="text-xs font-medium text-foreground">Repository Instructions</span>
<span className="text-xs text-muted-foreground">
Per-repo custom instructions injected into the agent's system prompt.
</span>
</div>
<CaretRightIcon className="size-3.5 shrink-0 text-muted-foreground" />
</Link>
</SettingsSection>
{error && <p className="text-xs text-destructive">{error}</p>}
</AppShell>
)

View file

@ -1617,6 +1617,20 @@
resolved "https://registry.yarnpkg.com/@oxc-project/types/-/types-0.133.0.tgz#2e282ef9e1d26e06b68ccd14b73f310a3b2cf7f8"
integrity sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==
"@monaco-editor/loader@^1.5.0":
version "1.7.0"
resolved "https://registry.yarnpkg.com/@monaco-editor/loader/-/loader-1.7.0.tgz#967aaa4601b19e913627688dfe8159d57549e793"
integrity sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==
dependencies:
state-local "^1.0.6"
"@monaco-editor/react@^4.7.0":
version "4.7.0"
resolved "https://registry.yarnpkg.com/@monaco-editor/react/-/react-4.7.0.tgz#35a1ec01bfe729f38bfc025df7b7bac145602a60"
integrity sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==
dependencies:
"@monaco-editor/loader" "^1.5.0"
"@package-json/types@^0.0.12":
version "0.0.12"
resolved "https://registry.yarnpkg.com/@package-json/types/-/types-0.0.12.tgz#4629e833ba128ed9880b6b7a947633ee22952462"
@ -6270,6 +6284,11 @@ minipass@^7.1.2:
resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.1.3.tgz#79389b4eb1bb2d003a9bba87d492f2bd37bdc65b"
integrity sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==
monaco-editor@^0.52.2:
version "0.52.2"
resolved "https://registry.yarnpkg.com/monaco-editor/-/monaco-editor-0.52.2.tgz#53c75a6fcc6802684e99fd1b2700299857002205"
integrity sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ==
ms@^2.1.3:
version "2.1.3"
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2"
@ -7516,6 +7535,11 @@ stackback@0.0.2:
resolved "https://registry.yarnpkg.com/stackback/-/stackback-0.0.2.tgz#1ac8a0d9483848d1695e418b6d031a3c3ce68e3b"
integrity sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==
state-local@^1.0.6:
version "1.0.7"
resolved "https://registry.yarnpkg.com/state-local/-/state-local-1.0.7.tgz#da50211d07f05748d53009bee46307a37db386d5"
integrity sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==
statuses@^2.0.1, statuses@^2.0.2, statuses@~2.0.2:
version "2.0.2"
resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.2.tgz#8f75eecef765b5e1cfcdc080da59409ed424e382"