diff --git a/src/lib/agent/runner/harness/pi/orchestrator-tools.ts b/src/lib/agent/runner/harness/pi/orchestrator-tools.ts index 42aa778c..83ebbadc 100644 --- a/src/lib/agent/runner/harness/pi/orchestrator-tools.ts +++ b/src/lib/agent/runner/harness/pi/orchestrator-tools.ts @@ -18,6 +18,7 @@ import { applyComplete, applyEnqueue, applyReadHandoffs, + COMPLETE_TASK_DESCRIPTION, ENQUEUE_MODEL_DESCRIPTION, HANDOFF_FIELDS, NOT_NEEDED_REASON_ASK, @@ -138,10 +139,9 @@ export function createPiOrchestratorTools( const completeTask = defineTool({ name: 'complete_task', label: 'Complete task', - description: - "Report the outcome of your task. Always call this exactly once when you finish, with a structured handoff for the next agent. Use status 'not needed' when the task does not apply to this project and you cannot do it (say why in the handoff) — not 'done'.", + description: COMPLETE_TASK_DESCRIPTION, promptSnippet: - 'complete_task(status, handoff) — report your outcome exactly once when done', + 'complete_task(status, handoff) — report your outcome exactly once when done, with goals/did/forNextAgent nested inside handoff', parameters: COMPLETE_PARAMS, execute(_id, args) { const res = applyComplete(ctx, args as CompleteArgs); diff --git a/src/lib/agent/runner/sequence/orchestrator/__tests__/handoff-schema-parity.test.ts b/src/lib/agent/runner/sequence/orchestrator/__tests__/handoff-schema-parity.test.ts index e4546b51..62b14780 100644 --- a/src/lib/agent/runner/sequence/orchestrator/__tests__/handoff-schema-parity.test.ts +++ b/src/lib/agent/runner/sequence/orchestrator/__tests__/handoff-schema-parity.test.ts @@ -5,8 +5,16 @@ * asked for a report section it could not submit. */ import { describe, it, expect } from 'vitest'; -import { HANDOFF_FIELDS, HANDOFF_SHAPE_KEYS } from '../queue-tools'; -import { PI_HANDOFF_PARAM_KEYS } from '../../../harness/pi/orchestrator-tools'; +import { + COMPLETE_TASK_DESCRIPTION, + HANDOFF_FIELDS, + HANDOFF_SHAPE_KEYS, + type OrchestratorToolsContext, +} from '../queue-tools'; +import { + createPiOrchestratorTools, + PI_HANDOFF_PARAM_KEYS, +} from '../../../harness/pi/orchestrator-tools'; describe('complete_task handoff schema', () => { it('exposes the same fields on both harnesses', () => { @@ -33,3 +41,29 @@ describe('complete_task handoff schema', () => { }, ); }); + +describe('complete_task description', () => { + const piCompleteTask = () => { + const ctx = { validTypes: [] } as unknown as OrchestratorToolsContext; + const tool = createPiOrchestratorTools(ctx).find( + (t) => (t as unknown as { name: string }).name === 'complete_task', + ); + return tool as unknown as { description: string }; + }; + + it('shares one tool description with the MCP server', () => { + expect(piCompleteTask().description).toBe(COMPLETE_TASK_DESCRIPTION); + }); + + // An agent that cannot see the nesting spends a turn on a rejected flat call. + it.each(['goals', 'did', 'forNextAgent'])( + 'names %s as a field that goes inside the nested handoff', + (field) => { + expect(COMPLETE_TASK_DESCRIPTION).toContain(field); + }, + ); + + it('says the handoff is nested', () => { + expect(COMPLETE_TASK_DESCRIPTION).toMatch(/nested object/); + }); +}); diff --git a/src/lib/agent/runner/sequence/orchestrator/queue-tools.ts b/src/lib/agent/runner/sequence/orchestrator/queue-tools.ts index 5f1fa344..2e295582 100644 --- a/src/lib/agent/runner/sequence/orchestrator/queue-tools.ts +++ b/src/lib/agent/runner/sequence/orchestrator/queue-tools.ts @@ -48,6 +48,22 @@ export const ENQUEUE_MODEL_DESCRIPTION = `Optional model override for this task. ...VALID_MODELS, ].join(', ')}.`; +/** + * The `complete_task` tool description, shared by both harnesses' schemas so + * the outcome contract cannot drift — the same discipline {@link HANDOFF_FIELDS} + * applies to the fields inside the handoff. + * + * The closing sentence names the nesting because agents kept learning it from a + * rejection instead: a first call with `goals`/`did`/`forNextAgent` at the top + * level fails schema validation, and reading that error back was the only place + * the shape was stated. Naming it where the agent fills the call in costs + * nothing, the same reasoning as {@link ENQUEUE_MODEL_DESCRIPTION}. + */ +export const COMPLETE_TASK_DESCRIPTION = + 'Report the outcome of your task. Always call this exactly once when you finish, with a structured handoff for the next agent. ' + + "Use status 'not needed' when the task does not apply to this project and you cannot do it (say why in the handoff) — not 'done'. " + + 'The handoff is one nested object: put `goals`, `did` and `forNextAgent` — all three required — inside `handoff`, along with any optional fields, never at the top level.'; + /** The per-task remark ask, shared by both harnesses' complete_task schemas. */ export const REMARK_ASK = 'What information or guidance would have been useful to have in the integration prompt or documentation for this task — specifically anything that would have prevented tool failures, erroneous edits, or other wasted turns.'; @@ -512,7 +528,7 @@ export function buildOrchestratorTools( const completeTask = tool( 'complete_task', - "Report the outcome of your task. Always call this exactly once when you finish, with a structured handoff for the next agent. Use status 'not needed' when the task does not apply to this project and you cannot do it (say why in the handoff) — not 'done'.", + COMPLETE_TASK_DESCRIPTION, COMPLETE_SHAPE, ((args: CompleteArgs) => { const res = applyComplete(ctx, args);