Skip to content

Commit 02a27f4

Browse files
serghei-devsergeyklay
authored andcommitted
feat(prompt): inject RuntimeStatusSuffix into first-turn agent prompt
Adds a RuntimeStatusSuffix constant to the prompt package and appends it to the composed prompt on the first turn of each worker run. The suffix instructs the agent how to signal blocked or needs-human-review status via the .sortie/status file, per the A2O protocol. - Add exported RuntimeStatusSuffix constant in internal/prompt/turn.go - Restructure first-turn guard in RunWorkerAttempt to nest the tool advertisement check inside an outer turnNumber==1 block, then unconditionally append the status suffix (ordering: template output → tool advertisement → status suffix) - Fix orchestrator_test.go first-turn prompt assertion to use strings.HasPrefix instead of exact equality, since the suffix is now appended after the template output - Add TestRuntimeStatusSuffixInjection with four subtests covering first-turn inclusion, ordering after tool advertisement, omission on continuation turns, and injection when the template renders empty Closes #231
1 parent 8756da3 commit 02a27f4

4 files changed

Lines changed: 253 additions & 6 deletions

File tree

internal/orchestrator/orchestrator_test.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2200,14 +2200,14 @@ func TestOrchestratorDynamicConfigReload(t *testing.T) {
22002200

22012201
if v, ok := capturedPrompts.Load("P-1"); !ok {
22022202
t.Error("no prompt captured for P-1")
2203-
} else if got, ok := v.(string); !ok || got != "do P-1" {
2204-
t.Errorf("prompt for P-1 = %q, want %q", got, "do P-1")
2203+
} else if got, ok := v.(string); !ok || !strings.HasPrefix(got, "do P-1") {
2204+
t.Errorf("prompt for P-1 = %q, want prefix %q", got, "do P-1")
22052205
}
22062206

22072207
if v, ok := capturedPrompts.Load("P-2"); !ok {
22082208
t.Error("no prompt captured for P-2")
2209-
} else if got, ok := v.(string); !ok || got != "review P-2" {
2210-
t.Errorf("prompt for P-2 = %q, want %q", got, "review P-2")
2209+
} else if got, ok := v.(string); !ok || !strings.HasPrefix(got, "review P-2") {
2210+
t.Errorf("prompt for P-2 = %q, want prefix %q", got, "review P-2")
22112211
}
22122212
})
22132213

internal/orchestrator/worker.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -544,8 +544,11 @@ func RunWorkerAttempt(ctx context.Context, issue domain.Issue, attempt *int, dep
544544
return
545545
}
546546

547-
if turnNumber == 1 && deps.ToolRegistry != nil && deps.ToolRegistry.Len() > 0 {
548-
rendered += "\n\n" + buildToolAdvertisement(deps.ToolRegistry, cfg.Tracker.Project)
547+
if turnNumber == 1 {
548+
if deps.ToolRegistry != nil && deps.ToolRegistry.Len() > 0 {
549+
rendered += "\n\n" + buildToolAdvertisement(deps.ToolRegistry, cfg.Tracker.Project)
550+
}
551+
rendered += "\n\n" + prompt.RuntimeStatusSuffix
549552
}
550553

551554
logger.Info("turn started", slog.Int("turn_number", turnNumber), slog.Int("max_turns", maxTurns))

internal/orchestrator/worker_test.go

Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2891,3 +2891,231 @@ func TestRunWorkerAttempt_A2OStatusSignal(t *testing.T) {
28912891
}
28922892
})
28932893
}
2894+
2895+
// TestRuntimeStatusSuffixInjection verifies the first-turn-only injection of
2896+
// prompt.RuntimeStatusSuffix into the prompt passed to RunTurn, including
2897+
// ordering relative to tool advertisement and the absence of the suffix on
2898+
// continuation turns.
2899+
func TestRuntimeStatusSuffixInjection(t *testing.T) {
2900+
t.Parallel()
2901+
2902+
// writeSoftStop writes "blocked" to the .sortie/status file inside the
2903+
// given workspace path so the worker exits cleanly after one turn.
2904+
writeSoftStop := func(wsPath string) {
2905+
statusDir := filepath.Join(wsPath, ".sortie")
2906+
if err := os.MkdirAll(statusDir, 0o755); err == nil {
2907+
_ = os.WriteFile(filepath.Join(statusDir, "status"), []byte("blocked\n"), 0o644)
2908+
}
2909+
}
2910+
2911+
t.Run("first_turn_contains_suffix", func(t *testing.T) {
2912+
t.Parallel()
2913+
2914+
tmpDir := t.TempDir()
2915+
cfg := defaultWorkerConfig(tmpDir)
2916+
cfg.Agent.MaxTurns = 5
2917+
2918+
var wsPath atomic.Value
2919+
var capturedPrompt string
2920+
var mu sync.Mutex
2921+
ec := newExitCapture()
2922+
2923+
deps := WorkerDeps{
2924+
TrackerAdapter: &mockTrackerAdapter{},
2925+
AgentAdapter: &mockAgentAdapter{
2926+
startSessionFn: func(_ context.Context, params domain.StartSessionParams) (domain.Session, error) {
2927+
wsPath.Store(params.WorkspacePath)
2928+
return domain.Session{ID: "sess-1"}, nil
2929+
},
2930+
runTurnFn: func(_ context.Context, session domain.Session, params domain.RunTurnParams) (domain.TurnResult, error) {
2931+
mu.Lock()
2932+
capturedPrompt = params.Prompt
2933+
mu.Unlock()
2934+
if p, ok := wsPath.Load().(string); ok && p != "" {
2935+
writeSoftStop(p)
2936+
}
2937+
return domain.TurnResult{SessionID: session.ID, ExitReason: domain.EventTurnCompleted}, nil
2938+
},
2939+
},
2940+
ConfigFunc: func() config.ServiceConfig { return cfg },
2941+
PromptTemplateFunc: func() *prompt.Template { return mustParseTemplate(t, "do work on {{ .issue.title }}") },
2942+
OnEvent: func(_ string, _ domain.AgentEvent) {},
2943+
OnExit: ec.onExit,
2944+
Logger: discardLogger(),
2945+
}
2946+
2947+
RunWorkerAttempt(context.Background(), workerTestIssue(), nil, deps)
2948+
2949+
ec.waitResult(t)
2950+
2951+
mu.Lock()
2952+
p := capturedPrompt
2953+
mu.Unlock()
2954+
2955+
if !strings.Contains(p, prompt.RuntimeStatusSuffix) {
2956+
t.Errorf("first-turn prompt missing RuntimeStatusSuffix:\n%s", p)
2957+
}
2958+
})
2959+
2960+
t.Run("suffix_after_tool_advertisement", func(t *testing.T) {
2961+
t.Parallel()
2962+
2963+
tmpDir := t.TempDir()
2964+
cfg := defaultWorkerConfig(tmpDir)
2965+
cfg.Agent.MaxTurns = 5
2966+
cfg.Tracker.Project = "TESTPROJ"
2967+
2968+
var wsPath atomic.Value
2969+
var capturedPrompt string
2970+
var mu sync.Mutex
2971+
ec := newExitCapture()
2972+
2973+
reg := domain.NewToolRegistry()
2974+
reg.Register(&stubAgentTool{toolName: "tracker_api", desc: "Query issues"})
2975+
2976+
deps := WorkerDeps{
2977+
TrackerAdapter: &mockTrackerAdapter{},
2978+
AgentAdapter: &mockAgentAdapter{
2979+
startSessionFn: func(_ context.Context, params domain.StartSessionParams) (domain.Session, error) {
2980+
wsPath.Store(params.WorkspacePath)
2981+
return domain.Session{ID: "sess-1"}, nil
2982+
},
2983+
runTurnFn: func(_ context.Context, session domain.Session, params domain.RunTurnParams) (domain.TurnResult, error) {
2984+
mu.Lock()
2985+
capturedPrompt = params.Prompt
2986+
mu.Unlock()
2987+
if p, ok := wsPath.Load().(string); ok && p != "" {
2988+
writeSoftStop(p)
2989+
}
2990+
return domain.TurnResult{SessionID: session.ID, ExitReason: domain.EventTurnCompleted}, nil
2991+
},
2992+
},
2993+
ConfigFunc: func() config.ServiceConfig { return cfg },
2994+
PromptTemplateFunc: func() *prompt.Template { return mustParseTemplate(t, "do work on {{ .issue.title }}") },
2995+
OnEvent: func(_ string, _ domain.AgentEvent) {},
2996+
OnExit: ec.onExit,
2997+
Logger: discardLogger(),
2998+
ToolRegistry: reg,
2999+
}
3000+
3001+
RunWorkerAttempt(context.Background(), workerTestIssue(), nil, deps)
3002+
3003+
ec.waitResult(t)
3004+
3005+
mu.Lock()
3006+
p := capturedPrompt
3007+
mu.Unlock()
3008+
3009+
toolIdx := strings.Index(p, "## Available Sortie tools")
3010+
if toolIdx < 0 {
3011+
t.Fatalf("first-turn prompt missing tool advertisement header:\n%s", p)
3012+
}
3013+
suffixIdx := strings.Index(p, prompt.RuntimeStatusSuffix)
3014+
if suffixIdx < 0 {
3015+
t.Fatalf("first-turn prompt missing RuntimeStatusSuffix:\n%s", p)
3016+
}
3017+
if suffixIdx <= toolIdx {
3018+
t.Errorf("RuntimeStatusSuffix (idx=%d) must appear after tool advertisement (idx=%d)", suffixIdx, toolIdx)
3019+
}
3020+
})
3021+
3022+
t.Run("continuation_turn_omits_suffix", func(t *testing.T) {
3023+
t.Parallel()
3024+
3025+
tmpDir := t.TempDir()
3026+
cfg := defaultWorkerConfig(tmpDir)
3027+
cfg.Agent.MaxTurns = 2
3028+
3029+
var prompts [2]string
3030+
var turnCounter atomic.Int64
3031+
var mu sync.Mutex
3032+
ec := newExitCapture()
3033+
3034+
deps := WorkerDeps{
3035+
TrackerAdapter: &mockTrackerAdapter{},
3036+
AgentAdapter: &mockAgentAdapter{
3037+
runTurnFn: func(_ context.Context, session domain.Session, params domain.RunTurnParams) (domain.TurnResult, error) {
3038+
n := turnCounter.Add(1)
3039+
mu.Lock()
3040+
if n <= 2 {
3041+
prompts[n-1] = params.Prompt
3042+
}
3043+
mu.Unlock()
3044+
return domain.TurnResult{SessionID: session.ID, ExitReason: domain.EventTurnCompleted}, nil
3045+
},
3046+
},
3047+
ConfigFunc: func() config.ServiceConfig { return cfg },
3048+
PromptTemplateFunc: func() *prompt.Template { return mustParseTemplate(t, "turn={{ .run.turn_number }}") },
3049+
OnEvent: func(_ string, _ domain.AgentEvent) {},
3050+
OnExit: ec.onExit,
3051+
Logger: discardLogger(),
3052+
}
3053+
3054+
RunWorkerAttempt(context.Background(), workerTestIssue(), nil, deps)
3055+
3056+
result := ec.waitResult(t)
3057+
if result.TurnsCompleted != 2 {
3058+
t.Fatalf("TurnsCompleted = %d, want 2", result.TurnsCompleted)
3059+
}
3060+
3061+
mu.Lock()
3062+
p1, p2 := prompts[0], prompts[1]
3063+
mu.Unlock()
3064+
3065+
if !strings.Contains(p1, prompt.RuntimeStatusSuffix) {
3066+
t.Errorf("turn 1 prompt missing RuntimeStatusSuffix:\n%s", p1)
3067+
}
3068+
if strings.Contains(p2, prompt.RuntimeStatusSuffix) {
3069+
t.Errorf("turn 2 prompt must not contain RuntimeStatusSuffix:\n%s", p2)
3070+
}
3071+
})
3072+
3073+
t.Run("empty_template_first_turn_contains_suffix", func(t *testing.T) {
3074+
t.Parallel()
3075+
3076+
tmpDir := t.TempDir()
3077+
cfg := defaultWorkerConfig(tmpDir)
3078+
cfg.Agent.MaxTurns = 5
3079+
3080+
var wsPath atomic.Value
3081+
var capturedPrompt string
3082+
var mu sync.Mutex
3083+
ec := newExitCapture()
3084+
3085+
deps := WorkerDeps{
3086+
TrackerAdapter: &mockTrackerAdapter{},
3087+
AgentAdapter: &mockAgentAdapter{
3088+
startSessionFn: func(_ context.Context, params domain.StartSessionParams) (domain.Session, error) {
3089+
wsPath.Store(params.WorkspacePath)
3090+
return domain.Session{ID: "sess-1"}, nil
3091+
},
3092+
runTurnFn: func(_ context.Context, session domain.Session, params domain.RunTurnParams) (domain.TurnResult, error) {
3093+
mu.Lock()
3094+
capturedPrompt = params.Prompt
3095+
mu.Unlock()
3096+
if p, ok := wsPath.Load().(string); ok && p != "" {
3097+
writeSoftStop(p)
3098+
}
3099+
return domain.TurnResult{SessionID: session.ID, ExitReason: domain.EventTurnCompleted}, nil
3100+
},
3101+
},
3102+
ConfigFunc: func() config.ServiceConfig { return cfg },
3103+
PromptTemplateFunc: func() *prompt.Template { return mustParseTemplate(t, "") },
3104+
OnEvent: func(_ string, _ domain.AgentEvent) {},
3105+
OnExit: ec.onExit,
3106+
Logger: discardLogger(),
3107+
}
3108+
3109+
RunWorkerAttempt(context.Background(), workerTestIssue(), nil, deps)
3110+
3111+
ec.waitResult(t)
3112+
3113+
mu.Lock()
3114+
p := capturedPrompt
3115+
mu.Unlock()
3116+
3117+
if !strings.Contains(p, prompt.RuntimeStatusSuffix) {
3118+
t.Errorf("first-turn prompt missing RuntimeStatusSuffix with empty template:\n%s", p)
3119+
}
3120+
})
3121+
}

internal/prompt/turn.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,22 @@ import (
1313
// such branching.
1414
const DefaultContinuationPrompt = "Continue working on this task. Review the current state of your work, check what remains to be done, and proceed with the next step. If you believe the task is complete, verify your changes and confirm completion."
1515

16+
// RuntimeStatusSuffix is a fixed instruction string appended to the agent
17+
// prompt on the first turn of each worker run. It informs the agent of
18+
// the A2O status-signaling protocol for reporting blocked or
19+
// review-needed status via the .sortie/status file.
20+
//
21+
// Continuation turns omit this suffix because the instruction persists
22+
// in the agent's conversation history from turn 1.
23+
const RuntimeStatusSuffix = `If you determine that you cannot make further progress on this task without human
24+
intervention, or if your work is complete and requires human review, signal the
25+
orchestrator by running:
26+
27+
mkdir -p .sortie && echo "blocked" > .sortie/status
28+
29+
Use "blocked" when you cannot proceed. Use "needs-human-review" when your work is
30+
complete and awaiting review. Do not write this file during normal productive work.`
31+
1632
// BuildTurnPrompt returns the rendered prompt for a single turn within a
1733
// worker session. turnNumber 1 is the initial turn; turnNumber 2 and above
1834
// are continuation turns. If a continuation turn renders to empty output,

0 commit comments

Comments
 (0)