Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 14 additions & 20 deletions backend/internal/session_manager/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -218,6 +218,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),
})
Expand Down Expand Up @@ -558,22 +559,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) {
Expand Down Expand Up @@ -612,17 +617,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).
Expand Down
62 changes: 47 additions & 15 deletions backend/internal/session_manager/manager_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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
Expand Down
Loading