mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-10-03 12:43:30 +00:00
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:
parent
d9f673f5a5
commit
da8f003933
13 changed files with 847 additions and 5 deletions
113
agent/dashboard/agent_instructions.py
Normal file
113
agent/dashboard/agent_instructions.py
Normal 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
|
||||||
|
|
@ -13,6 +13,15 @@ from fastapi.responses import RedirectResponse, Response, StreamingResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from .admin import is_admin
|
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 (
|
from .agent_usage import (
|
||||||
list_agent_usage_leaderboard,
|
list_agent_usage_leaderboard,
|
||||||
refresh_reviewer_stats_cache,
|
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)
|
_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:
|
def _api_base_url() -> str:
|
||||||
v = os.environ.get("DASHBOARD_API_BASE_URL", "").rstrip("/")
|
v = os.environ.get("DASHBOARD_API_BASE_URL", "").rstrip("/")
|
||||||
if not v:
|
if not v:
|
||||||
|
|
@ -609,7 +637,7 @@ async def list_repos(
|
||||||
async def api_list_review_styles(
|
async def api_list_review_styles(
|
||||||
session: dict[str, Any] = _SESSION_DEP,
|
session: dict[str, Any] = _SESSION_DEP,
|
||||||
) -> list[dict[str, Any]]:
|
) -> 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]] = []
|
out: list[dict[str, Any]] = []
|
||||||
for record in records:
|
for record in records:
|
||||||
if record.get("status") == "running":
|
if record.get("status") == "running":
|
||||||
|
|
@ -635,6 +663,7 @@ async def api_get_review_style(
|
||||||
session: dict[str, Any] = _SESSION_DEP,
|
session: dict[str, Any] = _SESSION_DEP,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
full_name = normalize_repo_full_name(full_name)
|
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)
|
record = await get_review_style(full_name)
|
||||||
if not record:
|
if not record:
|
||||||
raise HTTPException(404, "review style not found")
|
raise HTTPException(404, "review style not found")
|
||||||
|
|
@ -650,10 +679,10 @@ async def api_update_review_style_prompt(
|
||||||
session: dict[str, Any] = _SESSION_DEP,
|
session: dict[str, Any] = _SESSION_DEP,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
full_name = normalize_repo_full_name(full_name)
|
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)
|
record = await get_review_style(full_name)
|
||||||
if not record:
|
if not record:
|
||||||
raise HTTPException(404, "review style not found")
|
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)
|
return await set_custom_prompt(full_name, body.custom_prompt)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -683,8 +712,8 @@ async def api_cancel_review_style(
|
||||||
full_name: str,
|
full_name: str,
|
||||||
session: dict[str, Any] = _SESSION_DEP,
|
session: dict[str, Any] = _SESSION_DEP,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
del session
|
|
||||||
full_name = normalize_repo_full_name(full_name)
|
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)
|
record = await get_review_style(full_name)
|
||||||
if not record:
|
if not record:
|
||||||
raise HTTPException(404, "review style not found")
|
raise HTTPException(404, "review style not found")
|
||||||
|
|
@ -696,8 +725,8 @@ async def api_delete_review_style(
|
||||||
full_name: str,
|
full_name: str,
|
||||||
session: dict[str, Any] = _SESSION_DEP,
|
session: dict[str, Any] = _SESSION_DEP,
|
||||||
) -> Response:
|
) -> Response:
|
||||||
del session
|
|
||||||
full_name = normalize_repo_full_name(full_name)
|
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)
|
record = await get_review_style(full_name)
|
||||||
if not record:
|
if not record:
|
||||||
raise HTTPException(404, "review style not found")
|
raise HTTPException(404, "review style not found")
|
||||||
|
|
@ -708,6 +737,60 @@ async def api_delete_review_style(
|
||||||
return Response(status_code=204)
|
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")
|
@router.get("/agent-usage-leaderboard")
|
||||||
async def api_agent_usage_leaderboard(
|
async def api_agent_usage_leaderboard(
|
||||||
background_tasks: BackgroundTasks,
|
background_tasks: BackgroundTasks,
|
||||||
|
|
|
||||||
|
|
@ -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."""
|
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 = (
|
SYSTEM_PROMPT_TEMPLATE = (
|
||||||
WORKING_ENV_SECTION
|
WORKING_ENV_SECTION
|
||||||
+ TASK_OVERVIEW_SECTION
|
+ TASK_OVERVIEW_SECTION
|
||||||
|
|
@ -420,6 +434,7 @@ SYSTEM_PROMPT_TEMPLATE = (
|
||||||
+ COMMIT_PR_SECTION
|
+ COMMIT_PR_SECTION
|
||||||
+ "{pr_policy_override_section}"
|
+ "{pr_policy_override_section}"
|
||||||
+ "{collaboration_section}"
|
+ "{collaboration_section}"
|
||||||
|
+ "{repo_instructions_section}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -430,6 +445,7 @@ def construct_system_prompt(
|
||||||
triggering_user_identity: CollaboratorIdentity | None = None,
|
triggering_user_identity: CollaboratorIdentity | None = None,
|
||||||
create_prs: bool = False,
|
create_prs: bool = False,
|
||||||
default_repo: dict[str, str] | None = None,
|
default_repo: dict[str, str] | None = None,
|
||||||
|
repo_custom_instructions: str | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
default_prompt_section = _load_default_prompt()
|
default_prompt_section = _load_default_prompt()
|
||||||
if default_repo and default_repo.get("owner") and default_repo.get("name"):
|
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,
|
default_prompt_section=default_prompt_section,
|
||||||
pr_policy_override_section=ALWAYS_CREATE_PR_SECTION if create_prs else "",
|
pr_policy_override_section=ALWAYS_CREATE_PR_SECTION if create_prs else "",
|
||||||
collaboration_section=_render_collaboration_section(triggering_user_identity),
|
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_name=commit_identity_name,
|
||||||
commit_identity_email=commit_identity_email,
|
commit_identity_email=commit_identity_email,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -118,6 +118,21 @@ async def _resolve_prompt_default_repo(configurable: dict[str, Any]) -> dict[str
|
||||||
return None
|
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:
|
async def _start_langsmith_sandbox_if_needed(sandbox_backend: SandboxBackendProtocol) -> None:
|
||||||
"""Start a LangSmith sandbox before operations that require it to be running."""
|
"""Start a LangSmith sandbox before operations that require it to be running."""
|
||||||
if os.getenv("SANDBOX_TYPE", "langsmith") != "langsmith":
|
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)
|
logger.debug("Failed to record agent usage for thread %s", thread_id, exc_info=True)
|
||||||
|
|
||||||
prompt_default_repo = await _resolve_prompt_default_repo(configurable)
|
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)
|
logger.info("Returning agent with sandbox for thread %s", thread_id)
|
||||||
main_model = make_model(model_id, **model_kwargs)
|
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,
|
triggering_user_identity=triggering_user_identity,
|
||||||
create_prs=always_create_prs,
|
create_prs=always_create_prs,
|
||||||
default_repo=prompt_default_repo,
|
default_repo=prompt_default_repo,
|
||||||
|
repo_custom_instructions=repo_custom_instructions,
|
||||||
),
|
),
|
||||||
tools=[
|
tools=[
|
||||||
http_request,
|
http_request,
|
||||||
|
|
|
||||||
155
tests/test_agent_instructions.py
Normal file
155
tests/test_agent_instructions.py
Normal 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()
|
||||||
|
|
@ -14,6 +14,7 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui/react": "^1.4.1",
|
"@base-ui/react": "^1.4.1",
|
||||||
"@fontsource-variable/inter": "^5.2.8",
|
"@fontsource-variable/inter": "^5.2.8",
|
||||||
|
"@monaco-editor/react": "^4.7.0",
|
||||||
"@phosphor-icons/react": "^2.1.10",
|
"@phosphor-icons/react": "^2.1.10",
|
||||||
"@pierre/diffs": "^1.2.1",
|
"@pierre/diffs": "^1.2.1",
|
||||||
"@tailwindcss/vite": "^4.2.1",
|
"@tailwindcss/vite": "^4.2.1",
|
||||||
|
|
@ -29,6 +30,7 @@
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"diff": "^9.0.0",
|
"diff": "^9.0.0",
|
||||||
"lucide-react": "^1.16.0",
|
"lucide-react": "^1.16.0",
|
||||||
|
"monaco-editor": "^0.52.2",
|
||||||
"nitro": "latest",
|
"nitro": "latest",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
|
|
|
||||||
272
ui/src/components/AgentInstructionsPanel.tsx
Normal file
272
ui/src/components/AgentInstructionsPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
59
ui/src/components/InstructionsEditor.tsx
Normal file
59
ui/src/components/InstructionsEditor.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -225,6 +225,16 @@ export interface ReviewStyle {
|
||||||
updated_at?: string;
|
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 = {
|
export const api = {
|
||||||
me: () => request<SessionUser>("/me"),
|
me: () => request<SessionUser>("/me"),
|
||||||
options: () => request<OptionsPayload>("/options"),
|
options: () => request<OptionsPayload>("/options"),
|
||||||
|
|
@ -257,6 +267,23 @@ export const api = {
|
||||||
request<void>(`/review-styles/${encodeURIComponent(full_name)}`, {
|
request<void>(`/review-styles/${encodeURIComponent(full_name)}`, {
|
||||||
method: "DELETE",
|
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"),
|
getTeamSettings: () => request<TeamSettings>("/team-settings"),
|
||||||
saveTeamSettings: (body: TeamSettings) =>
|
saveTeamSettings: (body: TeamSettings) =>
|
||||||
request<TeamSettings>("/team-settings", { method: "PUT", body: JSON.stringify(body) }),
|
request<TeamSettings>("/team-settings", { method: "PUT", body: JSON.stringify(body) }),
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import { Route as AdminRouteImport } from './routes/admin'
|
||||||
import { Route as IndexRouteImport } from './routes/index'
|
import { Route as IndexRouteImport } from './routes/index'
|
||||||
import { Route as AgentsIndexRouteImport } from './routes/agents/index'
|
import { Route as AgentsIndexRouteImport } from './routes/agents/index'
|
||||||
import { Route as ReviewStylesRouteImport } from './routes/review_.styles'
|
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 AgentsThreadIdRouteImport } from './routes/agents/$threadId'
|
||||||
import { Route as AgentsAutomationsIndexRouteImport } from './routes/agents/automations/index'
|
import { Route as AgentsAutomationsIndexRouteImport } from './routes/agents/automations/index'
|
||||||
import { Route as ReviewRepositoriesOwnerRouteImport } from './routes/review_.repositories.$owner'
|
import { Route as ReviewRepositoriesOwnerRouteImport } from './routes/review_.repositories.$owner'
|
||||||
|
|
@ -81,6 +82,11 @@ const ReviewStylesRoute = ReviewStylesRouteImport.update({
|
||||||
path: '/review/styles',
|
path: '/review/styles',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AgentsInstructionsRoute = AgentsInstructionsRouteImport.update({
|
||||||
|
id: '/agents_/instructions',
|
||||||
|
path: '/agents/instructions',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const AgentsThreadIdRoute = AgentsThreadIdRouteImport.update({
|
const AgentsThreadIdRoute = AgentsThreadIdRouteImport.update({
|
||||||
id: '/$threadId',
|
id: '/$threadId',
|
||||||
path: '/$threadId',
|
path: '/$threadId',
|
||||||
|
|
@ -120,6 +126,7 @@ export interface FileRoutesByFullPath {
|
||||||
'/usage': typeof UsageRoute
|
'/usage': typeof UsageRoute
|
||||||
'/agents/$threadId': typeof AgentsThreadIdRoute
|
'/agents/$threadId': typeof AgentsThreadIdRoute
|
||||||
'/review/styles': typeof ReviewStylesRoute
|
'/review/styles': typeof ReviewStylesRoute
|
||||||
|
'/agents/instructions': typeof AgentsInstructionsRoute
|
||||||
'/agents/': typeof AgentsIndexRoute
|
'/agents/': typeof AgentsIndexRoute
|
||||||
'/agents/automations/$scheduleId': typeof AgentsAutomationsScheduleIdRoute
|
'/agents/automations/$scheduleId': typeof AgentsAutomationsScheduleIdRoute
|
||||||
'/agents/automations/new': typeof AgentsAutomationsNewRoute
|
'/agents/automations/new': typeof AgentsAutomationsNewRoute
|
||||||
|
|
@ -137,6 +144,7 @@ export interface FileRoutesByTo {
|
||||||
'/usage': typeof UsageRoute
|
'/usage': typeof UsageRoute
|
||||||
'/agents/$threadId': typeof AgentsThreadIdRoute
|
'/agents/$threadId': typeof AgentsThreadIdRoute
|
||||||
'/review/styles': typeof ReviewStylesRoute
|
'/review/styles': typeof ReviewStylesRoute
|
||||||
|
'/agents/instructions': typeof AgentsInstructionsRoute
|
||||||
'/agents': typeof AgentsIndexRoute
|
'/agents': typeof AgentsIndexRoute
|
||||||
'/agents/automations/$scheduleId': typeof AgentsAutomationsScheduleIdRoute
|
'/agents/automations/$scheduleId': typeof AgentsAutomationsScheduleIdRoute
|
||||||
'/agents/automations/new': typeof AgentsAutomationsNewRoute
|
'/agents/automations/new': typeof AgentsAutomationsNewRoute
|
||||||
|
|
@ -156,6 +164,7 @@ export interface FileRoutesById {
|
||||||
'/usage': typeof UsageRoute
|
'/usage': typeof UsageRoute
|
||||||
'/agents/$threadId': typeof AgentsThreadIdRoute
|
'/agents/$threadId': typeof AgentsThreadIdRoute
|
||||||
'/review_/styles': typeof ReviewStylesRoute
|
'/review_/styles': typeof ReviewStylesRoute
|
||||||
|
'/agents_/instructions': typeof AgentsInstructionsRoute
|
||||||
'/agents/': typeof AgentsIndexRoute
|
'/agents/': typeof AgentsIndexRoute
|
||||||
'/agents/automations/$scheduleId': typeof AgentsAutomationsScheduleIdRoute
|
'/agents/automations/$scheduleId': typeof AgentsAutomationsScheduleIdRoute
|
||||||
'/agents/automations/new': typeof AgentsAutomationsNewRoute
|
'/agents/automations/new': typeof AgentsAutomationsNewRoute
|
||||||
|
|
@ -176,6 +185,7 @@ export interface FileRouteTypes {
|
||||||
| '/usage'
|
| '/usage'
|
||||||
| '/agents/$threadId'
|
| '/agents/$threadId'
|
||||||
| '/review/styles'
|
| '/review/styles'
|
||||||
|
| '/agents/instructions'
|
||||||
| '/agents/'
|
| '/agents/'
|
||||||
| '/agents/automations/$scheduleId'
|
| '/agents/automations/$scheduleId'
|
||||||
| '/agents/automations/new'
|
| '/agents/automations/new'
|
||||||
|
|
@ -193,6 +203,7 @@ export interface FileRouteTypes {
|
||||||
| '/usage'
|
| '/usage'
|
||||||
| '/agents/$threadId'
|
| '/agents/$threadId'
|
||||||
| '/review/styles'
|
| '/review/styles'
|
||||||
|
| '/agents/instructions'
|
||||||
| '/agents'
|
| '/agents'
|
||||||
| '/agents/automations/$scheduleId'
|
| '/agents/automations/$scheduleId'
|
||||||
| '/agents/automations/new'
|
| '/agents/automations/new'
|
||||||
|
|
@ -211,6 +222,7 @@ export interface FileRouteTypes {
|
||||||
| '/usage'
|
| '/usage'
|
||||||
| '/agents/$threadId'
|
| '/agents/$threadId'
|
||||||
| '/review_/styles'
|
| '/review_/styles'
|
||||||
|
| '/agents_/instructions'
|
||||||
| '/agents/'
|
| '/agents/'
|
||||||
| '/agents/automations/$scheduleId'
|
| '/agents/automations/$scheduleId'
|
||||||
| '/agents/automations/new'
|
| '/agents/automations/new'
|
||||||
|
|
@ -229,6 +241,7 @@ export interface RootRouteChildren {
|
||||||
ReviewRoute: typeof ReviewRoute
|
ReviewRoute: typeof ReviewRoute
|
||||||
UsageRoute: typeof UsageRoute
|
UsageRoute: typeof UsageRoute
|
||||||
ReviewStylesRoute: typeof ReviewStylesRoute
|
ReviewStylesRoute: typeof ReviewStylesRoute
|
||||||
|
AgentsInstructionsRoute: typeof AgentsInstructionsRoute
|
||||||
ReviewRepositoriesOwnerRoute: typeof ReviewRepositoriesOwnerRoute
|
ReviewRepositoriesOwnerRoute: typeof ReviewRepositoriesOwnerRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -311,6 +324,13 @@ declare module '@tanstack/react-router' {
|
||||||
preLoaderRoute: typeof ReviewStylesRouteImport
|
preLoaderRoute: typeof ReviewStylesRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/agents_/instructions': {
|
||||||
|
id: '/agents_/instructions'
|
||||||
|
path: '/agents/instructions'
|
||||||
|
fullPath: '/agents/instructions'
|
||||||
|
preLoaderRoute: typeof AgentsInstructionsRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/agents/$threadId': {
|
'/agents/$threadId': {
|
||||||
id: '/agents/$threadId'
|
id: '/agents/$threadId'
|
||||||
path: '/$threadId'
|
path: '/$threadId'
|
||||||
|
|
@ -379,6 +399,7 @@ const rootRouteChildren: RootRouteChildren = {
|
||||||
ReviewRoute: ReviewRoute,
|
ReviewRoute: ReviewRoute,
|
||||||
UsageRoute: UsageRoute,
|
UsageRoute: UsageRoute,
|
||||||
ReviewStylesRoute: ReviewStylesRoute,
|
ReviewStylesRoute: ReviewStylesRoute,
|
||||||
|
AgentsInstructionsRoute: AgentsInstructionsRoute,
|
||||||
ReviewRepositoriesOwnerRoute: ReviewRepositoriesOwnerRoute,
|
ReviewRepositoriesOwnerRoute: ReviewRepositoriesOwnerRoute,
|
||||||
}
|
}
|
||||||
export const routeTree = rootRouteImport
|
export const routeTree = rootRouteImport
|
||||||
|
|
|
||||||
36
ui/src/routes/agents_.instructions.tsx
Normal file
36
ui/src/routes/agents_.instructions.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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 { useEffect, useRef, useState } from "react"
|
||||||
|
|
||||||
import type { ModelOption } from "@/lib/api"
|
import type { ModelOption } from "@/lib/api"
|
||||||
|
|
@ -316,6 +317,21 @@ function CloudAgentsPage() {
|
||||||
</div>
|
</div>
|
||||||
</SettingsSection>
|
</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>}
|
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||||
</AppShell>
|
</AppShell>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
24
ui/yarn.lock
24
ui/yarn.lock
|
|
@ -1617,6 +1617,20 @@
|
||||||
resolved "https://registry.yarnpkg.com/@oxc-project/types/-/types-0.133.0.tgz#2e282ef9e1d26e06b68ccd14b73f310a3b2cf7f8"
|
resolved "https://registry.yarnpkg.com/@oxc-project/types/-/types-0.133.0.tgz#2e282ef9e1d26e06b68ccd14b73f310a3b2cf7f8"
|
||||||
integrity sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==
|
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":
|
"@package-json/types@^0.0.12":
|
||||||
version "0.0.12"
|
version "0.0.12"
|
||||||
resolved "https://registry.yarnpkg.com/@package-json/types/-/types-0.0.12.tgz#4629e833ba128ed9880b6b7a947633ee22952462"
|
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"
|
resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.1.3.tgz#79389b4eb1bb2d003a9bba87d492f2bd37bdc65b"
|
||||||
integrity sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==
|
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:
|
ms@^2.1.3:
|
||||||
version "2.1.3"
|
version "2.1.3"
|
||||||
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2"
|
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"
|
resolved "https://registry.yarnpkg.com/stackback/-/stackback-0.0.2.tgz#1ac8a0d9483848d1695e418b6d031a3c3ce68e3b"
|
||||||
integrity sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==
|
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:
|
statuses@^2.0.1, statuses@^2.0.2, statuses@~2.0.2:
|
||||||
version "2.0.2"
|
version "2.0.2"
|
||||||
resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.2.tgz#8f75eecef765b5e1cfcdc080da59409ed424e382"
|
resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.2.tgz#8f75eecef765b5e1cfcdc080da59409ed424e382"
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue