fix(session-manager): deliver orchestrator instructions via system prompt (#189)

Orchestrator role definitions and worker coordination hints were being
prepended/appended to the user-facing prompt string. They now go into
SystemPrompt in LaunchConfig so agents receive them as standing instructions
rather than part of the human's task request.

Closes #182

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Harshit Singh Bhandari 2026-06-11 21:49:22 +05:30 committed by GitHub
parent 53ca81286a
commit 9b4651c612
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 61 additions and 35 deletions

View File

@ -163,7 +163,7 @@ func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Sess
// so a project can default workers to one agent and orchestrators to another.
cfg.Harness = effectiveHarness(cfg.Harness, cfg.Kind, project.Config)
prompt, err := m.buildSpawnPrompt(ctx, cfg)
prompt, systemPrompt, err := m.buildSpawnTexts(ctx, cfg)
if err != nil {
return domain.SessionRecord{}, fmt.Errorf("spawn: prompt: %w", err)
}
@ -220,6 +220,7 @@ func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Sess
SessionID: string(id),
WorkspacePath: ws.Path,
Prompt: prompt,
SystemPrompt: systemPrompt,
IssueID: string(cfg.IssueID),
Config: effectiveAgentConfig(cfg.Kind, project.Config),
})
@ -578,22 +579,26 @@ func buildPrompt(cfg ports.SpawnConfig) string {
return cfg.Prompt
}
func (m *Manager) buildSpawnPrompt(ctx context.Context, cfg ports.SpawnConfig) (string, error) {
prompt := buildPrompt(cfg)
// buildSpawnTexts returns the user-facing prompt and the system prompt to
// deliver separately to the agent. Orchestrator role instructions and worker
// coordination hints are placed in the system prompt so they are treated as
// standing instructions rather than part of the human's task request.
func (m *Manager) buildSpawnTexts(ctx context.Context, cfg ports.SpawnConfig) (prompt, systemPrompt string, err error) {
prompt = buildPrompt(cfg)
switch cfg.Kind {
case domain.KindOrchestrator:
return appendPromptSection(orchestratorPrompt(cfg.ProjectID), prompt), nil
systemPrompt = orchestratorPrompt(cfg.ProjectID)
case domain.KindWorker:
orchestratorID, ok, err := m.activeOrchestratorSessionID(ctx, cfg.ProjectID)
if err != nil {
return "", err
orchestratorID, ok, lookupErr := m.activeOrchestratorSessionID(ctx, cfg.ProjectID)
if lookupErr != nil {
return "", "", lookupErr
}
if ok {
prompt = appendPromptSection(prompt, workerOrchestratorPrompt(orchestratorID))
systemPrompt = workerOrchestratorPrompt(orchestratorID)
}
}
return prompt, nil
return prompt, systemPrompt, nil
}
func (m *Manager) activeOrchestratorSessionID(ctx context.Context, project domain.ProjectID) (domain.SessionID, bool, error) {
@ -632,17 +637,6 @@ An active orchestrator session exists for this project. If you hit a true blocke
Only ping the orchestrator for true blockers, cross-session coordination, or decisions that cannot be resolved within your own task.`, orchestratorID)
}
func appendPromptSection(prompt, section string) string {
switch {
case prompt == "":
return section
case section == "":
return prompt
default:
return prompt + "\n\n" + section
}
}
// spawnEnv builds the runtime environment: the per-project env vars first, then
// the AO-internal vars last so they always win (a project cannot override
// AO_SESSION_ID and friends).

View File

@ -155,10 +155,12 @@ func (fakeAgents) Agent(domain.AgentHarness) (ports.Agent, bool) { return fakeAg
type recordingAgent struct {
fakeAgent
lastConfig ports.AgentConfig
lastLaunch ports.LaunchConfig
}
func (a *recordingAgent) GetLaunchCommand(_ context.Context, cfg ports.LaunchConfig) ([]string, error) {
a.lastConfig = cfg.Config
a.lastLaunch = cfg
return []string{"launch"}, nil
}
@ -496,59 +498,89 @@ func TestSpawn_DefaultsBranchFromSessionID(t *testing.T) {
}
func TestSpawnWorker_AppendsActiveOrchestratorContact(t *testing.T) {
m, st, _, _ := newManager()
st := newFakeStore()
st.num = 1
st.sessions["mer-1"] = domain.SessionRecord{ID: "mer-1", ProjectID: "mer", Kind: domain.KindOrchestrator}
agent := &recordingAgent{}
rt := &fakeRuntime{}
ws := &fakeWorkspace{}
lookPath := func(string) (string, error) { return "/bin/true", nil }
m := New(Deps{Runtime: rt, Agents: singleAgent{agent: agent}, Workspace: ws, Store: st, Messenger: &fakeMessenger{}, Lifecycle: &fakeLCM{store: st}, LookPath: lookPath})
s, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, Prompt: "do it"})
if err != nil {
t.Fatal(err)
}
prompt := st.sessions[s.ID].Metadata.Prompt
// The user prompt must be preserved and stored in metadata as-is.
if got := st.sessions[s.ID].Metadata.Prompt; got != "do it" {
t.Fatalf("metadata prompt = %q, want %q", got, "do it")
}
// Coordination instructions must be in the system prompt, not the user prompt.
systemPrompt := agent.lastLaunch.SystemPrompt
for _, want := range []string{
"do it",
"## Orchestrator coordination",
`ao send --session mer-1 --message "<your message>"`,
"Only ping the orchestrator for true blockers, cross-session coordination",
} {
if !strings.Contains(prompt, want) {
t.Fatalf("prompt missing %q:\n%s", want, prompt)
if !strings.Contains(systemPrompt, want) {
t.Fatalf("system prompt missing %q:\n%s", want, systemPrompt)
}
}
if strings.Contains(agent.lastLaunch.Prompt, "## Orchestrator coordination") {
t.Fatalf("orchestrator coordination must not be in the user prompt:\n%s", agent.lastLaunch.Prompt)
}
}
func TestSpawnWorker_SkipsTerminatedOrchestratorContact(t *testing.T) {
m, st, _, _ := newManager()
st := newFakeStore()
st.num = 1
st.sessions["mer-1"] = domain.SessionRecord{ID: "mer-1", ProjectID: "mer", Kind: domain.KindOrchestrator, IsTerminated: true}
agent := &recordingAgent{}
rt := &fakeRuntime{}
ws := &fakeWorkspace{}
lookPath := func(string) (string, error) { return "/bin/true", nil }
m := New(Deps{Runtime: rt, Agents: singleAgent{agent: agent}, Workspace: ws, Store: st, Messenger: &fakeMessenger{}, Lifecycle: &fakeLCM{store: st}, LookPath: lookPath})
s, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, Prompt: "do it"})
_, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, Prompt: "do it"})
if err != nil {
t.Fatal(err)
}
prompt := st.sessions[s.ID].Metadata.Prompt
if strings.Contains(prompt, "## Orchestrator coordination") || strings.Contains(prompt, "ao send --session mer-1") {
t.Fatalf("terminated orchestrator should not be added to prompt:\n%s", prompt)
systemPrompt := agent.lastLaunch.SystemPrompt
if strings.Contains(systemPrompt, "## Orchestrator coordination") || strings.Contains(systemPrompt, "ao send --session mer-1") {
t.Fatalf("terminated orchestrator should not be added to system prompt:\n%s", systemPrompt)
}
}
func TestSpawnOrchestrator_UsesCoordinatorPrompt(t *testing.T) {
m, st, _, _ := newManager()
s, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindOrchestrator})
st := newFakeStore()
agent := &recordingAgent{}
rt := &fakeRuntime{}
ws := &fakeWorkspace{}
lookPath := func(string) (string, error) { return "/bin/true", nil }
m := New(Deps{Runtime: rt, Agents: singleAgent{agent: agent}, Workspace: ws, Store: st, Messenger: &fakeMessenger{}, Lifecycle: &fakeLCM{store: st}, LookPath: lookPath})
_, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindOrchestrator})
if err != nil {
t.Fatal(err)
}
prompt := st.sessions[s.ID].Metadata.Prompt
// Coordinator instructions must be in the system prompt, not the user prompt.
systemPrompt := agent.lastLaunch.SystemPrompt
for _, want := range []string{
"You are the human-facing coordinator for project mer",
`ao spawn --project mer --prompt "<clear worker task>"`,
"`ao send`",
"avoid doing implementation yourself unless it is necessary",
} {
if !strings.Contains(prompt, want) {
t.Fatalf("prompt missing %q:\n%s", want, prompt)
if !strings.Contains(systemPrompt, want) {
t.Fatalf("system prompt missing %q:\n%s", want, systemPrompt)
}
}
if strings.Contains(agent.lastLaunch.Prompt, "You are the human-facing coordinator") {
t.Fatalf("coordinator role must not be in the user prompt:\n%s", agent.lastLaunch.Prompt)
}
}
// TestRestore_RefusesIncompleteHandle covers Bug 2: a terminated row whose