feat: add project prompt rules

This commit is contained in:
whoisasx 2026-06-29 22:34:28 +05:30
parent 09b517aea1
commit 7297307af5
13 changed files with 313 additions and 50 deletions

View File

@ -89,14 +89,17 @@ type roleOverride struct {
// client. The CLI sets common fields via flags and the whole object via
// --config-json.
type projectConfig struct {
DefaultBranch string `json:"defaultBranch,omitempty"`
SessionPrefix string `json:"sessionPrefix,omitempty"`
Env map[string]string `json:"env,omitempty"`
Symlinks []string `json:"symlinks,omitempty"`
PostCreate []string `json:"postCreate,omitempty"`
AgentConfig agentConfig `json:"agentConfig,omitempty"`
Worker roleOverride `json:"worker,omitempty"`
Orchestrator roleOverride `json:"orchestrator,omitempty"`
DefaultBranch string `json:"defaultBranch,omitempty"`
SessionPrefix string `json:"sessionPrefix,omitempty"`
Env map[string]string `json:"env,omitempty"`
Symlinks []string `json:"symlinks,omitempty"`
PostCreate []string `json:"postCreate,omitempty"`
AgentRules string `json:"agentRules,omitempty"`
AgentRulesFile string `json:"agentRulesFile,omitempty"`
OrchestratorRules string `json:"orchestratorRules,omitempty"`
AgentConfig agentConfig `json:"agentConfig,omitempty"`
Worker roleOverride `json:"worker,omitempty"`
Orchestrator roleOverride `json:"orchestrator,omitempty"`
}
// setConfigRequest mirrors the daemon's SetConfigInput body for
@ -112,6 +115,9 @@ type projectSetConfigOptions struct {
permission string
workerAgent string
orchestratorAgent string
agentRules string
agentRulesFile string
orchestratorRules string
env []string
symlink []string
postCreate []string
@ -259,7 +265,7 @@ func newProjectSetConfigCommand(ctx *commandContext) *cobra.Command {
Use: "set-config <id>",
Short: "Set the per-project config",
Long: "Replace a project's per-project config (branch, session prefix, env, " +
"symlinks, post-create, agent model/permissions, role overrides). The config " +
"symlinks, post-create, rules, agent model/permissions, role overrides). The config " +
"is resolved when a session spawns.\n\n" +
"Set fields via flags, pass the whole object with --config-json, or --clear " +
"to remove all config.",
@ -297,6 +303,9 @@ func newProjectSetConfigCommand(ctx *commandContext) *cobra.Command {
f.StringVar(&opts.permission, "permission", "", "Permission mode: default, accept-edits, auto, bypass-permissions")
f.StringVar(&opts.workerAgent, "worker-agent", "", "Harness override for worker sessions")
f.StringVar(&opts.orchestratorAgent, "orchestrator-agent", "", "Harness override for orchestrator sessions")
f.StringVar(&opts.agentRules, "agent-rules", "", "Project-specific standing instructions for worker sessions")
f.StringVar(&opts.agentRulesFile, "agent-rules-file", "", "Repo-relative file containing worker standing instructions")
f.StringVar(&opts.orchestratorRules, "orchestrator-rules", "", "Project-specific standing instructions for orchestrator sessions")
f.StringArrayVar(&opts.env, "env", nil, "Env var KEY=VALUE forwarded into sessions (repeatable)")
f.StringArrayVar(&opts.symlink, "symlink", nil, "Repo-relative path to symlink into workspaces (repeatable)")
f.StringArrayVar(&opts.postCreate, "post-create", nil, "Command to run after workspace creation (repeatable)")
@ -327,14 +336,17 @@ func buildProjectConfig(opts projectSetConfigOptions) (projectConfig, error) {
return projectConfig{}, err
}
cfg := projectConfig{
DefaultBranch: opts.defaultBranch,
SessionPrefix: opts.sessionPrefix,
Env: env,
Symlinks: opts.symlink,
PostCreate: opts.postCreate,
AgentConfig: agentConfig{Model: opts.model, Permissions: opts.permission},
Worker: roleOverride{Agent: opts.workerAgent},
Orchestrator: roleOverride{Agent: opts.orchestratorAgent},
DefaultBranch: opts.defaultBranch,
SessionPrefix: opts.sessionPrefix,
Env: env,
Symlinks: opts.symlink,
PostCreate: opts.postCreate,
AgentRules: opts.agentRules,
AgentRulesFile: opts.agentRulesFile,
OrchestratorRules: opts.orchestratorRules,
AgentConfig: agentConfig{Model: opts.model, Permissions: opts.permission},
Worker: roleOverride{Agent: opts.workerAgent},
Orchestrator: roleOverride{Agent: opts.orchestratorAgent},
}
if reflect.DeepEqual(cfg, projectConfig{}) {
return projectConfig{}, usageError{errors.New("usage: provide at least one config flag, --config-json, or --clear")}

View File

@ -12,6 +12,7 @@ import (
type projectCapture struct {
method string
path string
body string
}
func projectServer(t *testing.T, status int, respBody string) (*httptest.Server, *projectCapture) {
@ -24,6 +25,11 @@ func projectServer(t *testing.T, status int, respBody string) (*httptest.Server,
http.NotFound(w, r)
return
}
data, err := io.ReadAll(r.Body)
if err != nil {
t.Errorf("read request body: %v", err)
}
capture.body = string(data)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_, _ = io.WriteString(w, respBody)
@ -167,6 +173,36 @@ func TestProjectGet_NotFound(t *testing.T) {
}
}
func TestProjectSetConfig_RulesFlags(t *testing.T) {
cfg := setConfigEnv(t)
srv, capture := projectServer(t, http.StatusOK, `{"status":"ok","project":{"id":"demo","config":{"agentRules":"Run tests.","agentRulesFile":"docs/rules.md","orchestratorRules":"Delegate."}}}`)
writeRunFileFor(t, cfg, srv)
out, errOut, err := executeCLI(t, Deps{
ProcessAlive: func(int) bool { return true },
}, "project", "set-config", "demo",
"--agent-rules", "Run tests.",
"--agent-rules-file", "docs/rules.md",
"--orchestrator-rules", "Delegate.",
)
if err != nil {
t.Fatalf("unexpected error: %v\nstderr=%s", err, errOut)
}
if capture.method != http.MethodPut || capture.path != "/api/v1/projects/demo/config" {
t.Fatalf("request = %s %s, want PUT /api/v1/projects/demo/config", capture.method, capture.path)
}
var got setConfigRequest
if err := json.Unmarshal([]byte(capture.body), &got); err != nil {
t.Fatalf("decode request body: %v\nbody=%s", err, capture.body)
}
if got.Config.AgentRules != "Run tests." || got.Config.AgentRulesFile != "docs/rules.md" || got.Config.OrchestratorRules != "Delegate." {
t.Fatalf("rules config = %#v", got.Config)
}
if !strings.Contains(out, "updated config for project demo") {
t.Fatalf("output missing update message:\n%s", out)
}
}
func TestProjectRemove_RequiresID(t *testing.T) {
setConfigEnv(t)
_, _, err := executeCLI(t, Deps{}, "project", "rm")

View File

@ -13,10 +13,10 @@ import (
// validated; there is no free-form map.
//
// Only fields with a live consumer are modeled: DefaultBranch, Env, Symlinks,
// PostCreate, AgentConfig, and the role overrides are consumed at spawn;
// SessionPrefix feeds the display prefix. Settings whose consumers do not yet
// exist (tracker/SCM per-project config, prompt rules) are intentionally absent
// and land in focused follow-up PRs alongside the code that reads them.
// PostCreate, AgentConfig, prompt rules, and the role overrides are consumed at
// spawn; SessionPrefix feeds the display prefix. Settings whose consumers do not
// yet exist (tracker/SCM per-project config) are intentionally absent and land in
// focused follow-up PRs alongside the code that reads them.
type ProjectConfig struct {
// DefaultBranch is the base branch new session worktrees are created from.
DefaultBranch string `json:"defaultBranch,omitempty"`
@ -31,6 +31,15 @@ type ProjectConfig struct {
// PostCreate are shell commands run in the workspace after it is created.
PostCreate []string `json:"postCreate,omitempty"`
// AgentRules are project-specific standing instructions for worker sessions.
AgentRules string `json:"agentRules,omitempty"`
// AgentRulesFile is a repo-relative Markdown/text file whose contents are
// appended to AgentRules for worker sessions.
AgentRulesFile string `json:"agentRulesFile,omitempty"`
// OrchestratorRules are project-specific standing instructions for
// orchestrator sessions.
OrchestratorRules string `json:"orchestratorRules,omitempty"`
// AgentConfig is the default agent config for the project.
AgentConfig AgentConfig `json:"agentConfig,omitempty"`
// Worker and Orchestrator are role-specific harness/agent-config overrides.
@ -123,6 +132,9 @@ func (c ProjectConfig) Validate() error {
return fmt.Errorf("symlink %q: %w", s, err)
}
}
if err := validateRepoRelative(c.AgentRulesFile); err != nil {
return fmt.Errorf("agentRulesFile %q: %w", c.AgentRulesFile, err)
}
for i, rv := range c.Reviewers {
if !rv.Harness.IsKnown() {
return fmt.Errorf("reviewers[%d].harness: unknown harness %q", i, rv.Harness)

View File

@ -23,6 +23,9 @@ func TestProjectConfigValidate(t *testing.T) {
{"symlink parent escape", ProjectConfig{Symlinks: []string{"../escape"}}, true},
{"symlink embedded parent", ProjectConfig{Symlinks: []string{"a/../../b"}}, true},
{"symlink bare ..", ProjectConfig{Symlinks: []string{".."}}, true},
{"good prompt rules", ProjectConfig{AgentRules: "Run tests.", AgentRulesFile: "docs/agent-rules.md", OrchestratorRules: "Delegate work."}, false},
{"agent rules file absolute path", ProjectConfig{AgentRulesFile: "/etc/passwd"}, true},
{"agent rules file parent escape", ProjectConfig{AgentRulesFile: "../rules.md"}, true},
{"good reviewers", ProjectConfig{Reviewers: []ReviewerConfig{{Harness: ReviewerClaudeCode}}}, false},
{"unknown reviewer harness", ProjectConfig{Reviewers: []ReviewerConfig{{Harness: "nope"}}}, true},
{"worker harness is not auto a reviewer", ProjectConfig{Reviewers: []ReviewerConfig{{Harness: ReviewerHarness(HarnessCodex)}}}, true},

View File

@ -1889,6 +1889,10 @@ components:
properties:
agentConfig:
$ref: '#/components/schemas/AgentConfig'
agentRules:
type: string
agentRulesFile:
type: string
defaultBranch:
type: string
env:
@ -1897,6 +1901,8 @@ components:
type: object
orchestrator:
$ref: '#/components/schemas/RoleOverride'
orchestratorRules:
type: string
postCreate:
items:
type: string

View File

@ -332,18 +332,23 @@ func TestProjectsAPI_RejectsUnknownConfigKeys(t *testing.T) {
body, status, _ = doRequest(t, srv, "PUT", "/api/v1/projects/rej/config", `{"config":{"defaultBranch":"develop"},"surprise":"!"}`)
assertErrorCode(t, body, status, http.StatusBadRequest, "INVALID_JSON")
// PUT a config body with a removed field inside the nested config — the
// canonical regression: agentRules / tracker are no longer modelled, so
// projects can't sneak them back in.
// Prompt rules are now modeled and accepted in project config.
body, status, _ = doRequest(t, srv, "PUT", "/api/v1/projects/rej/config", `{"config":{"agentRules":"x"}}`)
assertErrorCode(t, body, status, http.StatusBadRequest, "INVALID_JSON")
if status != http.StatusOK {
t.Fatalf("agentRules config = %d, want 200; body=%s", status, body)
}
// A still-unknown nested config field is rejected, so misspellings cannot be
// silently persisted.
body, status, _ = doRequest(t, srv, "PUT", "/api/v1/projects/rej/config", `{"config":{"tracker":{"plugin":"github"}}}`)
assertErrorCode(t, body, status, http.StatusBadRequest, "INVALID_JSON")
// POST /projects gets the same gate, so add-time config rides the same rail.
otherRepo := gitRepo(t, "rejects-unknown-add")
body, status, _ = doRequest(t, srv, "POST", "/api/v1/projects", `{"path":`+quote(otherRepo)+`,"projectId":"rej2","config":{"orchestratorRules":"x"}}`)
assertErrorCode(t, body, status, http.StatusBadRequest, "INVALID_JSON")
if status != http.StatusCreated {
t.Fatalf("orchestratorRules add config = %d, want 201; body=%s", status, body)
}
}
func TestProjectsRoutes_LegacyUnregistered(t *testing.T) {

View File

@ -14,8 +14,11 @@ var ErrSessionNotFound = errors.New("session not found")
type SpawnConfig struct {
ProjectID domain.ProjectID
IssueID domain.IssueID
Kind domain.SessionKind
Harness domain.AgentHarness
Branch string
Prompt string
// IssueContext is optional pre-fetched tracker context for the task prompt.
// Standing rules stay in SystemPrompt; issue facts belong to the user task.
IssueContext string
Kind domain.SessionKind
Harness domain.AgentHarness
Branch string
Prompt string
}

View File

@ -370,9 +370,11 @@ func TestManager_SetConfig(t *testing.T) {
}
cfg := domain.ProjectConfig{
DefaultBranch: "develop",
Env: map[string]string{"FOO": "bar"},
AgentConfig: domain.AgentConfig{Model: "claude-opus-4-5"},
DefaultBranch: "develop",
Env: map[string]string{"FOO": "bar"},
AgentRules: "Run focused tests.",
OrchestratorRules: "Delegate implementation.",
AgentConfig: domain.AgentConfig{Model: "claude-opus-4-5"},
}
proj, err := m.SetConfig(ctx, "ao", project.SetConfigInput{Config: cfg})
if err != nil {
@ -393,6 +395,9 @@ func TestManager_SetConfig(t *testing.T) {
if got.Project == nil || got.Project.Config == nil || got.Project.Config.Env["FOO"] != "bar" {
t.Fatalf("Get config = %#v", got.Project)
}
if got.Project.Config.AgentRules != "Run focused tests." || got.Project.Config.OrchestratorRules != "Delegate implementation." {
t.Fatalf("Get rules config = %#v", got.Project.Config)
}
// An invalid permission value is rejected when set.
_, err = m.SetConfig(ctx, "ao", project.SetConfigInput{Config: domain.ProjectConfig{AgentConfig: domain.AgentConfig{Permissions: "yolo"}}})

View File

@ -950,10 +950,20 @@ func defaultSessionBranch(id domain.SessionID, kind domain.SessionKind, prefix s
}
func buildPrompt(cfg ports.SpawnConfig) string {
if cfg.Prompt == "" && cfg.IssueID != "" {
return fmt.Sprintf("Work on issue %s. Use the issue context in your standing instructions.", cfg.IssueID)
issueContext := strings.TrimSpace(cfg.IssueContext)
if cfg.Prompt != "" {
if cfg.Kind == domain.KindWorker && issueContext != "" {
return strings.TrimRight(cfg.Prompt, "\n") + "\n\n" + issueContextSection(issueContext)
}
return cfg.Prompt
}
return cfg.Prompt
if cfg.IssueID == "" {
return ""
}
if cfg.Kind == domain.KindWorker && issueContext != "" {
return fmt.Sprintf("Work on issue %s. Use the issue context below as task context.\n\n%s", cfg.IssueID, issueContextSection(issueContext))
}
return fmt.Sprintf("Work on issue %s. Issue details were not pre-fetched; start by reading the issue, then implement.", cfg.IssueID)
}
// buildSpawnTexts returns the user-facing prompt and the system prompt to
@ -976,25 +986,85 @@ func (m *Manager) buildSpawnTexts(ctx context.Context, cfg ports.SpawnConfig) (p
// rather than persisting them, so a restored worker points at the orchestrator
// that is active now, not the one from its original spawn.
func (m *Manager) buildSystemPrompt(ctx context.Context, kind domain.SessionKind, projectID domain.ProjectID) (string, error) {
var base string
project, err := m.loadProject(ctx, projectID)
if err != nil {
return "", err
}
sections := make([]string, 0, 4)
switch kind {
case domain.KindOrchestrator:
base = orchestratorPrompt(projectID)
sections = append(sections, orchestratorPrompt(projectID))
if rules := strings.TrimSpace(project.Config.OrchestratorRules); rules != "" {
sections = append(sections, "## Project-specific orchestrator rules\n"+rules)
}
case domain.KindWorker:
sections = append(sections, workerRolePrompt())
orchestratorID, ok, err := m.activeOrchestratorSessionID(ctx, projectID)
if err != nil {
return "", err
}
if ok {
base = workerOrchestratorPrompt(orchestratorID) + "\n\n" + workerMultiPRPrompt()
} else {
base = workerMultiPRPrompt()
sections = append(sections, workerOrchestratorPrompt(orchestratorID))
}
sections = append(sections, workerMultiPRPrompt())
rules, err := projectAgentRules(project)
if err != nil {
return "", err
}
if rules != "" {
sections = append(sections, "## Project Rules\n"+rules)
}
}
if base == "" {
if len(sections) == 0 {
return "", nil
}
return base + systemPromptGuard, nil
return strings.Join(sections, "\n\n") + systemPromptGuard, nil
}
func issueContextSection(issueContext string) string {
return "## Issue Context\n" + issueContext
}
func projectAgentRules(project domain.ProjectRecord) (string, error) {
cfg := project.Config
parts := make([]string, 0, 2)
if rules := strings.TrimSpace(cfg.AgentRules); rules != "" {
parts = append(parts, rules)
}
if rel := strings.TrimSpace(cfg.AgentRulesFile); rel != "" {
path, err := projectRelativeFile(project.Path, rel)
if err != nil {
return "", fmt.Errorf("agentRulesFile: %w", err)
}
data, err := os.ReadFile(path) //nolint:gosec // path is project config validated as repo-relative
if err != nil {
return "", fmt.Errorf("read agentRulesFile %s: %w", rel, err)
}
if rules := strings.TrimSpace(string(data)); rules != "" {
parts = append(parts, rules)
}
}
return strings.Join(parts, "\n\n"), nil
}
func projectRelativeFile(projectPath, rel string) (string, error) {
if strings.TrimSpace(projectPath) == "" {
return "", fmt.Errorf("project path is required")
}
trimmed := strings.TrimSpace(rel)
if filepath.IsAbs(trimmed) || strings.HasPrefix(trimmed, "/") || strings.HasPrefix(trimmed, `\`) {
return "", fmt.Errorf("path must be repo-relative and must not escape the project root")
}
clean := filepath.Clean(trimmed)
if clean == "." || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
return "", fmt.Errorf("path must be repo-relative and must not escape the project root")
}
for _, seg := range strings.Split(filepath.ToSlash(clean), "/") {
if seg == ".." {
return "", fmt.Errorf("path must be repo-relative and must not escape the project root")
}
}
return filepath.Join(projectPath, clean), nil
}
func (m *Manager) activeOrchestratorSessionID(ctx context.Context, project domain.ProjectID) (domain.SessionID, bool, error) {
@ -1046,6 +1116,12 @@ Message workers with `+"`ao send`"+`, for example:
Use workers for focused implementation tasks, track their progress, synthesize their results, and only step into implementation directly for true emergencies or small coordination fixes.`, project, project)
}
func workerRolePrompt() string {
return `## Worker role
You are an implementation worker for this AO session. Focus on the assigned task, inspect the relevant code and tests before editing, keep changes scoped, verify the behavior you touched, and report blockers clearly.`
}
func workerOrchestratorPrompt(orchestratorID domain.SessionID) string {
return fmt.Sprintf(`## Orchestrator coordination

View File

@ -814,7 +814,7 @@ func TestSpawnWorker_IssueWithoutPromptGetsFallbackTaskPrompt(t *testing.T) {
t.Fatal(err)
}
want := "Work on issue 2272. Use the issue context in your standing instructions."
want := "Work on issue 2272. Issue details were not pre-fetched; start by reading the issue, then implement."
if agent.lastLaunch.Prompt != want {
t.Fatalf("launch prompt = %q, want %q", agent.lastLaunch.Prompt, want)
}
@ -823,6 +823,65 @@ func TestSpawnWorker_IssueWithoutPromptGetsFallbackTaskPrompt(t *testing.T) {
}
}
func TestSpawnWorker_ProjectRulesInSystemPrompt(t *testing.T) {
projectDir := t.TempDir()
if err := os.MkdirAll(filepath.Join(projectDir, "docs"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(projectDir, "docs", "rules.md"), []byte("File rule.\n"), 0o644); err != nil {
t.Fatal(err)
}
cfg := testRoleAgents()
cfg.AgentRules = "Inline rule."
cfg.AgentRulesFile = "docs/rules.md"
st := newFakeStore()
st.projects["mer"] = domain.ProjectRecord{ID: "mer", Path: projectDir, Config: cfg}
agent := &recordingAgent{}
lookPath := func(string) (string, error) { return "/bin/true", nil }
m := New(Deps{Runtime: &fakeRuntime{}, Agents: singleAgent{agent: agent}, Workspace: &fakeWorkspace{}, Store: st, Messenger: &fakeMessenger{}, Lifecycle: &fakeLCM{store: st}, LookPath: lookPath})
if _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker}); err != nil {
t.Fatal(err)
}
systemPrompt := agent.lastLaunch.SystemPrompt
for _, want := range []string{"## Worker role", "## Project Rules", "Inline rule.", "File rule."} {
if !strings.Contains(systemPrompt, want) {
t.Fatalf("system prompt missing %q:\n%s", want, systemPrompt)
}
}
if strings.Contains(agent.lastLaunch.Prompt, "Inline rule.") || strings.Contains(agent.lastLaunch.Prompt, "File rule.") {
t.Fatalf("project rules must not be in task prompt:\n%s", agent.lastLaunch.Prompt)
}
}
func TestSpawnWorker_IssueContextStaysInTaskPrompt(t *testing.T) {
st := newFakeStore()
st.projects["mer"] = domain.ProjectRecord{ID: "mer", Config: testRoleAgents()}
agent := &recordingAgent{}
lookPath := func(string) (string, error) { return "/bin/true", nil }
m := New(Deps{Runtime: &fakeRuntime{}, Agents: singleAgent{agent: agent}, Workspace: &fakeWorkspace{}, Store: st, Messenger: &fakeMessenger{}, Lifecycle: &fakeLCM{store: st}, LookPath: lookPath})
_, err := m.Spawn(ctx, ports.SpawnConfig{
ProjectID: "mer",
Kind: domain.KindWorker,
IssueID: "2272",
IssueContext: "Title: Enrich prompts\nBody: Include issue context.",
})
if err != nil {
t.Fatal(err)
}
for _, want := range []string{"Work on issue 2272.", "## Issue Context", "Title: Enrich prompts"} {
if !strings.Contains(agent.lastLaunch.Prompt, want) {
t.Fatalf("task prompt missing %q:\n%s", want, agent.lastLaunch.Prompt)
}
}
if strings.Contains(agent.lastLaunch.SystemPrompt, "Title: Enrich prompts") || strings.Contains(agent.lastLaunch.SystemPrompt, "## Issue Context") {
t.Fatalf("issue context must not be in system prompt:\n%s", agent.lastLaunch.SystemPrompt)
}
}
func TestSpawnWorker_SkipsTerminatedOrchestratorContact(t *testing.T) {
st := newFakeStore()
st.projects["mer"] = domain.ProjectRecord{ID: "mer", Config: testRoleAgents()}
@ -881,6 +940,29 @@ func TestSpawnOrchestrator_UsesCoordinatorPrompt(t *testing.T) {
}
}
func TestSpawnOrchestrator_ProjectRulesInSystemPrompt(t *testing.T) {
cfg := testRoleAgents()
cfg.AgentRules = "Worker-only rule."
cfg.OrchestratorRules = "Coordinate through workers."
st := newFakeStore()
st.projects["mer"] = domain.ProjectRecord{ID: "mer", Config: cfg}
agent := &recordingAgent{}
lookPath := func(string) (string, error) { return "/bin/true", nil }
m := New(Deps{Runtime: &fakeRuntime{}, Agents: singleAgent{agent: agent}, Workspace: &fakeWorkspace{}, Store: st, Messenger: &fakeMessenger{}, Lifecycle: &fakeLCM{store: st}, LookPath: lookPath})
if _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindOrchestrator}); err != nil {
t.Fatal(err)
}
systemPrompt := agent.lastLaunch.SystemPrompt
if !strings.Contains(systemPrompt, "## Project-specific orchestrator rules") || !strings.Contains(systemPrompt, "Coordinate through workers.") {
t.Fatalf("orchestrator rules missing from system prompt:\n%s", systemPrompt)
}
if strings.Contains(systemPrompt, "Worker-only rule.") {
t.Fatalf("worker rules must not be in orchestrator system prompt:\n%s", systemPrompt)
}
}
// TestSystemPrompt_AppendsConfidentialityGuard: every non-empty system prompt
// must carry the guard that tells the agent not to reveal its standing
// instructions on request. Without it, "give me your system prompt" dumps the
@ -926,6 +1008,9 @@ func TestSystemPrompt_AppendsConfidentialityGuard(t *testing.T) {
// recomputed and handed to the agent's native resume command.
func TestRestore_OrchestratorRederivesSystemPrompt(t *testing.T) {
st := newFakeStore()
cfg := testRoleAgents()
cfg.OrchestratorRules = "Use workers for implementation."
st.projects["mer"] = domain.ProjectRecord{ID: "mer", Config: cfg}
st.sessions["mer-1"] = domain.SessionRecord{
ID: "mer-1", ProjectID: "mer", Kind: domain.KindOrchestrator, IsTerminated: true,
Metadata: domain.SessionMetadata{WorkspacePath: "/ws/mer-1", Branch: "b", AgentSessionID: "agent-x"},
@ -941,6 +1026,9 @@ func TestRestore_OrchestratorRederivesSystemPrompt(t *testing.T) {
if !strings.Contains(agent.lastRestore.SystemPrompt, "You are the human-facing coordinator for project mer") {
t.Fatalf("restore system prompt missing coordinator role:\n%s", agent.lastRestore.SystemPrompt)
}
if !strings.Contains(agent.lastRestore.SystemPrompt, "Use workers for implementation.") {
t.Fatalf("restore system prompt missing project rules:\n%s", agent.lastRestore.SystemPrompt)
}
wantPath := filepath.Join(dataDir, "prompts", "mer-1", "system.md")
if agent.lastRestore.SystemPromptFile != wantPath {
t.Fatalf("restore system prompt file = %q, want %q", agent.lastRestore.SystemPromptFile, wantPath)

View File

@ -80,12 +80,15 @@ func TestProjectConfigRoundTrips(t *testing.T) {
// A config with mixed field kinds (scalar, map, list, nested) survives the
// JSON round trip.
cfg := domain.ProjectConfig{
DefaultBranch: "develop",
Env: map[string]string{"FOO": "bar"},
Symlinks: []string{".env"},
PostCreate: []string{"echo hi"},
AgentConfig: domain.AgentConfig{Model: "claude-opus-4-5", Permissions: domain.PermissionModeAcceptEdits},
Worker: domain.RoleOverride{Harness: domain.HarnessCodex},
DefaultBranch: "develop",
Env: map[string]string{"FOO": "bar"},
Symlinks: []string{".env"},
PostCreate: []string{"echo hi"},
AgentRules: "Run focused tests.",
AgentRulesFile: "docs/agent-rules.md",
OrchestratorRules: "Keep workers unblocked.",
AgentConfig: domain.AgentConfig{Model: "claude-opus-4-5", Permissions: domain.PermissionModeAcceptEdits},
Worker: domain.RoleOverride{Harness: domain.HarnessCodex},
}
if err := s.UpsertProject(ctx, domain.ProjectRecord{
ID: "cfg", Path: "/tmp/cfg", RegisteredAt: now, Config: cfg,

View File

@ -128,6 +128,17 @@ flowchart LR
**Output:** Executable command line as argv array
### Session Prompt and Rules Flow
AO splits prompt material into two channels before it reaches an adapter:
- **System prompt** — Derived standing instructions for the session role. This includes AO's hardcoded worker/orchestrator behavior, coordination rules, PR workflow guidance, project-specific `agentRules`/`agentRulesFile` for workers, project-specific `orchestratorRules` for orchestrators, and the confidentiality guard.
- **Task prompt** — The concrete work request. This includes the user's explicit prompt, issue fallback text, and any pre-fetched issue context. Issue facts are task context, not permanent standing rules.
Generated system prompts are not stored as canonical session state. On spawn and restore, the session manager re-derives them from hardcoded prompt text, project config, and current project/session state, then passes `SystemPrompt` and/or `SystemPromptFile` through `LaunchConfig` or `RestoreConfig`. Any prompt file under `AO_DATA_DIR/prompts/<session-id>/system.md` is a launch artifact only.
Adapters should map `SystemPrompt`/`SystemPromptFile` to the strongest native system/developer-instruction mechanism they support. Adapter-specific compatibility gaps are handled inside adapters; the session manager owns composition, not per-agent flag details.
#### GetPromptDeliveryStrategy()
Reports how the prompt is delivered to the agent:

View File

@ -661,11 +661,14 @@ export interface components {
};
ProjectConfig: {
agentConfig?: components["schemas"]["AgentConfig"];
agentRules?: string;
agentRulesFile?: string;
defaultBranch?: string;
env?: {
[key: string]: string;
};
orchestrator?: components["schemas"]["RoleOverride"];
orchestratorRules?: string;
postCreate?: string[];
reviewers?: components["schemas"]["DomainReviewerConfig"][];
sessionPrefix?: string;