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:
parent
53ca81286a
commit
9b4651c612
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue