feat: renamed subagent handoff flow to use yield instead of submit_result

- Renamed subagent completion flow from `submit_result` to `yield` across SDK tools, prompts, and docs.
- Updated executor/task handling to require and parse `yield` calls, replacing legacy submit-result extraction and state flags.
- Added `subagent-yield-reminder` and updated system prompts to require `yield` with `result.data` or `result.error`.
- Renamed hidden-tool and registration plumbing to `yield`, including discovery helpers and renderer/test surface.
This commit is contained in:
can1357
2026-04-26 00:29:10 +02:00
parent 6bd4cf24fd
commit ecd1554eba
30 changed files with 200 additions and 221 deletions
+3 -3
View File
@@ -187,7 +187,7 @@ READ-ONLY if applicable — list prohibited actions explicitly.
<output> <output>
What to return. Schema requirements. What to return. Schema requirements.
Call `submit_result` with findings when done. Call `yield` with findings when done.
</output> </output>
<critical> <critical>
@@ -577,11 +577,11 @@ READ-ONLY. You are STRICTLY PROHIBITED from:
2. Read key sections (not entire files) 2. Read key sections (not entire files)
3. Identify types, interfaces, key functions 3. Identify types, interfaces, key functions
4. Note dependencies between files 4. Note dependencies between files
5. Call `submit_result` with findings 5. Call `yield` with findings
</procedure> </procedure>
<critical> <critical>
Read-only. Call `submit_result` when done. This matters. Read-only. Call `yield` when done. This matters.
</critical> </critical>
``` ```
+3 -3
View File
@@ -228,12 +228,12 @@ Related APIs:
- Built-ins come from `createTools(...)` and `BUILTIN_TOOLS`. - Built-ins come from `createTools(...)` and `BUILTIN_TOOLS`.
- `toolNames` acts as an allowlist for built-ins. - `toolNames` acts as an allowlist for built-ins.
- `customTools` and extension-registered tools are still included. - `customTools` and extension-registered tools are still included.
- Hidden tools (for example `submit_result`) are opt-in unless required by options. - Hidden tools (for example `yield`) are opt-in unless required by options.
```ts ```ts
const { session } = await createAgentSession({ const { session } = await createAgentSession({
toolNames: ["read", "grep", "find", "write"], toolNames: ["read", "grep", "find", "write"],
requireSubmitResultTool: true, requireYieldTool: true,
}); });
``` ```
@@ -274,7 +274,7 @@ Use these when you want partial control without recreating internal discovery lo
For SDK consumers building orchestrators (similar to task executor flow): For SDK consumers building orchestrators (similar to task executor flow):
- `outputSchema`: passes structured output expectation into tool context - `outputSchema`: passes structured output expectation into tool context
- `requireSubmitResultTool`: forces `submit_result` tool inclusion - `requireYieldTool`: forces `yield` tool inclusion
- `taskDepth`: recursion-depth context for nested task sessions - `taskDepth`: recursion-depth context for nested task sessions
- `parentTaskPrefix`: artifact naming prefix for nested task outputs - `parentTaskPrefix`: artifact naming prefix for nested task outputs
+1 -1
View File
@@ -31,7 +31,7 @@ Task agents normalize into `AgentDefinition` (`src/task/types.ts`):
Parsing comes from frontmatter via `parseAgentFields()` (`src/discovery/helpers.ts`): Parsing comes from frontmatter via `parseAgentFields()` (`src/discovery/helpers.ts`):
- missing `name` or `description` => invalid (`null`), caller treats as parse failure - missing `name` or `description` => invalid (`null`), caller treats as parse failure
- `tools` accepts CSV or array; if provided, `submit_result` is auto-added - `tools` accepts CSV or array; if provided, `yield` is auto-added
- `spawns` accepts `*`, CSV, or array - `spawns` accepts `*`, CSV, or array
- backward-compat behavior: if `spawns` missing but `tools` includes `task`, `spawns` becomes `*` - backward-compat behavior: if `spawns` missing but `tools` includes `task`, `spawns` becomes `*`
- `output` is passed through as opaque schema data - `output` is passed through as opaque schema data
+1 -1
View File
@@ -544,7 +544,7 @@ describe("agentLoop with AgentMessage", () => {
const stream = new MockAssistantStream(); const stream = new MockAssistantStream();
queueMicrotask(() => { queueMicrotask(() => {
const partial = createAssistantMessage( const partial = createAssistantMessage(
[{ type: "toolCall", id: "tool-1", name: "submit_result", arguments: { data: { ok: true } } }], [{ type: "toolCall", id: "tool-1", name: "yield", arguments: { data: { ok: true } } }],
"toolUse", "toolUse",
); );
stream.push({ type: "start", partial }); stream.push({ type: "start", partial });
@@ -617,7 +617,7 @@ async function createClient(
// Azure OpenAI requires /deployments/{id}/chat/completions?api-version=YYYY-MM-DD. // Azure OpenAI requires /deployments/{id}/chat/completions?api-version=YYYY-MM-DD.
// The generic openai-completions path adds neither, producing silent 404s. // The generic openai-completions path adds neither, producing silent 404s.
let azureDefaultQuery: Record<string, string> | undefined; let azureDefaultQuery: Record<string, string> | undefined;
if (baseUrl && baseUrl.includes(".openai.azure.com")) { if (baseUrl?.includes(".openai.azure.com")) {
const apiVersion = $env.AZURE_OPENAI_API_VERSION || "2024-10-21"; const apiVersion = $env.AZURE_OPENAI_API_VERSION || "2024-10-21";
if (!baseUrl.includes("/deployments/")) { if (!baseUrl.includes("/deployments/")) {
baseUrl = `${baseUrl}/deployments/${model.id}`; baseUrl = `${baseUrl}/deployments/${model.id}`;
+2 -1
View File
@@ -1,9 +1,9 @@
# Changelog # Changelog
## [Unreleased] ## [Unreleased]
### Breaking Changes ### Breaking Changes
- Renamed the subagent completion contract from `submit_result` to `yield`, so subagent sessions must now finish with the `yield` tool and the `requireYieldTool` option; `submit_result`/`requireSubmitResultTool` and old completion calls are no longer recognized
- Changed the hashline and chunk anchor ID format from the prior hex-like tokens to two-letter BPE bigrams (for example `#th`), which invalidates previously captured `LINE#ID`/chunk selectors and requires re-reading to refresh anchors - Changed the hashline and chunk anchor ID format from the prior hex-like tokens to two-letter BPE bigrams (for example `#th`), which invalidates previously captured `LINE#ID`/chunk selectors and requires re-reading to refresh anchors
### Added ### Added
@@ -13,6 +13,7 @@
### Changed ### Changed
- Updated subagent reminders, prompts, and rendered subagent output to reference `yield` completion and report missing/final results from `yield` tool data
- Updated the `edit` workflow to treat `atom` mode like hashline mode for read output, so hashline anchors are shown when `atom` is selected - Updated the `edit` workflow to treat `atom` mode like hashline mode for read output, so hashline anchors are shown when `atom` is selected
- Adjusted patch/replace/chunk tooling to accept optional entry paths and to apply a top-level path default - Adjusted patch/replace/chunk tooling to accept optional entry paths and to apply a top-level path default
- Updated hashline/chunk selector parsing to the new stable bigram token set used for checksums - Updated hashline/chunk selector parsing to the new stable bigram token set used for checksums
+10 -10
View File
@@ -395,7 +395,7 @@ A `ToolFactory` is `(session: ToolSession) => Tool | null | Promise<Tool | null>
4. Computes effective gating (`isToolAllowed`) from settings and runtime state: 4. Computes effective gating (`isToolAllowed`) from settings and runtime state:
- feature toggles (`find.enabled`, `grep.enabled`, etc.) - feature toggles (`find.enabled`, `grep.enabled`, etc.)
- recursion guard for `task` (`task.maxRecursionDepth` vs `session.taskDepth`) - recursion guard for `task` (`task.maxRecursionDepth` vs `session.taskDepth`)
- submit-result mode (`requireSubmitResultTool`) and `todo_write` suppression - yield mode (`requireYieldTool`) and `todo_write` suppression
5. Instantiates selected tools in parallel with `Promise.all`, records slow factory timings when `PI_TIMING=1`, and wraps results with `wrapToolWithMetaNotice`. 5. Instantiates selected tools in parallel with `Promise.all`, records slow factory timings when `PI_TIMING=1`, and wraps results with `wrapToolWithMetaNotice`.
6. Includes `resolve` only when at least one instantiated tool has `deferrable: true` (deferred preview/apply workflows). 6. Includes `resolve` only when at least one instantiated tool has `deferrable: true` (deferred preview/apply workflows).
@@ -864,7 +864,7 @@ mapWithConcurrencyLimit(...)
└── ... └── ...
│ │
▼ ▼
submit_result/fallback normalization yield/fallback normalization
│ │
▼ ▼
aggregated task results (+ optional worktree patches) aggregated task results (+ optional worktree patches)
@@ -924,21 +924,21 @@ What _is_ isolated is execution context and artifacts, not process memory:
- Adds `task` tool automatically when `agent.spawns` is set and recursion depth permits. - Adds `task` tool automatically when `agent.spawns` is set and recursion depth permits.
- Removes `task` when max recursion depth is reached (`task.maxRecursionDepth`). - Removes `task` when max recursion depth is reached (`task.maxRecursionDepth`).
- Expands legacy `exec` alias into `python` and/or `bash` based on `python.toolMode`. - Expands legacy `exec` alias into `python` and/or `bash` based on `python.toolMode`.
- Forces `requireSubmitResultTool: true` in `createAgentSession(...)`. - Forces `requireYieldTool: true` in `createAgentSession(...)`.
- Filters parent-owned tools out of child tools (`todo_write` is removed). - Filters parent-owned tools out of child tools (`todo_write` is removed).
If parent MCP connections exist, executor creates in-process MCP proxy tools with `createMCPProxyTools(...)` so children reuse parent MCP connectivity rather than creating independent MCP sessions. If parent MCP connections exist, executor creates in-process MCP proxy tools with `createMCPProxyTools(...)` so children reuse parent MCP connectivity rather than creating independent MCP sessions.
## Submit/Result Contract and Completion Semantics ## Submit/Result Contract and Completion Semantics
`executor.ts` enforces structured completion around `submit_result`: `executor.ts` enforces structured completion around `yield`:
- Tracks tool events and extracted data through `subprocessToolRegistry` handlers. - Tracks tool events and extracted data through `subprocessToolRegistry` handlers.
- Retries reminder prompts up to 3 times (`MAX_SUBMIT_RESULT_RETRIES`) using `subagent-submit-reminder.md` if `submit_result` was not called. - Retries reminder prompts up to 3 times (`MAX_YIELD_RETRIES`) using `subagent-yield-reminder.md` if `yield` was not called.
- Final output normalization is centralized in `finalizeSubprocessOutput(...)`: - Final output normalization is centralized in `finalizeSubprocessOutput(...)`:
- If `submit_result.status === "aborted"`, task is converted to an aborted result payload. - If `yield.status === "aborted"`, task is converted to an aborted result payload.
- If missing `submit_result`, fallback attempts JSON parse/validation against output schema. - If missing `yield`, fallback attempts JSON parse/validation against output schema.
- Emits warnings when `submit_result` is missing/null and fallback cannot safely validate. - Emits warnings when `yield` is missing/null and fallback cannot safely validate.
This module also accumulates token/cost usage from assistant `message_end` events and truncates returned output with `truncateTail(...)` using `MAX_OUTPUT_BYTES` and `MAX_OUTPUT_LINES`. This module also accumulates token/cost usage from assistant `message_end` events and truncates returned output with `truncateTail(...)` using `MAX_OUTPUT_BYTES` and `MAX_OUTPUT_LINES`.
@@ -1132,7 +1132,7 @@ Primary file: `packages/coding-agent/src/tools/index.ts`.
- `export const BUILTIN_TOOLS: Record<string, ToolFactory> = { ... }` - `export const BUILTIN_TOOLS: Record<string, ToolFactory> = { ... }`
- Key is the external tool name (e.g. `"read"`, `"web_search"`). - Key is the external tool name (e.g. `"read"`, `"web_search"`).
4. If it should be hidden/system-only, register under `HIDDEN_TOOLS` instead. 4. If it should be hidden/system-only, register under `HIDDEN_TOOLS` instead.
- Existing hidden names: `submit_result`, `report_finding`, `exit_plan_mode`, `resolve`. - Existing hidden names: `yield`, `report_finding`, `exit_plan_mode`, `resolve`.
5. Wire feature gates in `isToolAllowed(name)` when the tool needs runtime enable/disable behavior. 5. Wire feature gates in `isToolAllowed(name)` when the tool needs runtime enable/disable behavior.
- Existing gates use `session.settings.get("<tool>.enabled")` and recursion limits for `task`. - Existing gates use `session.settings.get("<tool>.enabled")` and recursion limits for `task`.
6. If the tool should be selectable by type, update `ToolName = keyof typeof BUILTIN_TOOLS` consumers as needed. 6. If the tool should be selectable by type, update `ToolName = keyof typeof BUILTIN_TOOLS` consumers as needed.
@@ -1141,7 +1141,7 @@ Notes from current behavior:
- `createTools()` always injects `exit_plan_mode` when `toolNames` are specified. - `createTools()` always injects `exit_plan_mode` when `toolNames` are specified.
- `resolve` is included only when at least one active tool is marked `deferrable: true` (built-in or extension/custom). - `resolve` is included only when at least one active tool is marked `deferrable: true` (built-in or extension/custom).
- `submit_result` is force-added when `session.requireSubmitResultTool === true`. - `yield` is force-added when `session.requireYieldTool === true`.
- Python/Bash availability is mode-driven (`PI_PY`, `python.toolMode`) and can auto-fallback to bash. - Python/Bash availability is mode-driven (`PI_PY`, `python.toolMode`) and can auto-fallback to bash.
### Playbook: add an RPC command ### Playbook: add an RPC command
@@ -19,4 +19,4 @@ Return concise JSON object with:
Consider how file's changes relate to above files. Consider how file's changes relate to above files.
{{/if}} {{/if}}
Call submit_result tool with JSON payload. Call yield tool with JSON payload.
@@ -221,9 +221,9 @@ export function parseAgentFields(frontmatter: Record<string, unknown>): ParsedAg
let tools = parseArrayOrCSV(frontmatter.tools)?.map(tool => tool.toLowerCase()); let tools = parseArrayOrCSV(frontmatter.tools)?.map(tool => tool.toLowerCase());
// Subagents with explicit tool lists always need submit_result // Subagents with explicit tool lists always need yield
if (tools && !tools.includes("submit_result")) { if (tools && !tools.includes("yield")) {
tools = [...tools, "submit_result"]; tools = [...tools, "yield"];
} }
// Parse spawns field (array, "*", or CSV) // Parse spawns field (array, "*", or CSV)
File diff suppressed because one or more lines are too long
@@ -1178,8 +1178,8 @@
return html; return html;
} }
function renderSubmitResult(name, args, result, ctx) { function renderYield(name, args, result, ctx) {
let html = toolHead('submit_result'); let html = toolHead('yield');
if (args.data !== undefined) { if (args.data !== undefined) {
html += '<div class="tool-output"><pre>' + escapeHtml(JSON.stringify(args.data, null, 2)) + '</pre></div>'; html += '<div class="tool-output"><pre>' + escapeHtml(JSON.stringify(args.data, null, 2)) + '</pre></div>';
} }
@@ -1287,7 +1287,7 @@
gh_search_issues: renderGh, gh_search_issues: renderGh,
gh_search_prs: renderGh, gh_search_prs: renderGh,
render_mermaid: renderMermaid, render_mermaid: renderMermaid,
submit_result: renderSubmitResult, yield: renderYield,
report_finding: renderReportFinding, report_finding: renderReportFinding,
report_tool_issue: renderReportToolIssue, report_tool_issue: renderReportToolIssue,
calc: renderCalc, calc: renderCalc,
@@ -98,7 +98,7 @@ Before acting, determine what kind of question this is:
- For API signatures: copy verbatim from source. You **MUST NOT** paraphrase or reconstruct from memory. - For API signatures: copy verbatim from source. You **MUST NOT** paraphrase or reconstruct from memory.
## 5. Report ## 5. Report
- Call `submit_result` with structured findings. - Call `yield` with structured findings.
- Every `sources` entry **MUST** include a verbatim excerpt. - Every `sources` entry **MUST** include a verbatim excerpt.
- The `api` array **MUST** contain exact signatures copied from source. - The `api` array **MUST** contain exact signatures copied from source.
- Clean up cloned repos: `rm -rf /tmp/librarian-*`. - Clean up cloned repos: `rm -rf /tmp/librarian-*`.
@@ -63,7 +63,7 @@ Your goal is to identify bugs the author would want fixed before merge.
1. Run `git diff` (or `gh pr diff <number>`) to view patch 1. Run `git diff` (or `gh pr diff <number>`) to view patch
2. Read modified files for full context 2. Read modified files for full context
3. Call `report_finding` per issue 3. Call `report_finding` per issue
4. Call `submit_result` with verdict 4. Call `yield` with verdict
Bash is read-only: `git diff`, `git log`, `git show`, `gh pr diff`. You **MUST NOT** make file edits or trigger builds. Bash is read-only: `git diff`, `git log`, `git show`, `gh pr diff`. You **MUST NOT** make file edits or trigger builds.
</procedure> </procedure>
@@ -111,7 +111,7 @@ Each `report_finding` requires:
- `file_path`: Absolute path - `file_path`: Absolute path
- `line_start`, `line_end`: Range ≤10 lines, must overlap diff - `line_start`, `line_end`: Range ≤10 lines, must overlap diff
Final `submit_result` call (payload under `result.data`): Final `yield` call (payload under `result.data`):
- `result.data.overall_correctness`: "correct" (no bugs/blockers) or "incorrect" - `result.data.overall_correctness`: "correct" (no bugs/blockers) or "incorrect"
- `result.data.explanation`: Plain text, 1-3 sentences summarizing verdict. Don't repeat findings (captured via `report_finding`). - `result.data.explanation`: Plain text, 1-3 sentences summarizing verdict. Don't repeat findings (captured via `report_finding`).
- `result.data.confidence`: 0.0-1.0 - `result.data.confidence`: 0.0-1.0
@@ -40,7 +40,7 @@ Reviewer **MUST**:
2. {{#if skipDiff}}**MUST** run `git diff`/`git show` for assigned files{{else}}**MUST** use diff hunks below (**MUST NOT** re-run git diff){{/if}} 2. {{#if skipDiff}}**MUST** run `git diff`/`git show` for assigned files{{else}}**MUST** use diff hunks below (**MUST NOT** re-run git diff){{/if}}
3. **MAY** read full file context as needed via `read` 3. **MAY** read full file context as needed via `read`
4. Call `report_finding` per issue 4. Call `report_finding` per issue
5. Call `submit_result` with verdict when done 5. Call `yield` with verdict when done
{{#if skipDiff}} {{#if skipDiff}}
### Diff Previews ### Diff Previews
@@ -1,11 +0,0 @@
<system-reminder>
You stopped without calling submit_result. This is reminder {{retryCount}} of {{maxRetries}}.
You **MUST** call submit_result as your only action now. Choose one:
- If task is complete: call submit_result with your result in `result.data`
- If task failed: call submit_result with `result.error` describing what happened
You **MUST NOT** give up if you can still complete the task through exploration (using available tools or repo context). If you submit an error, you **MUST** include what you tried and the exact blocker.
You **MUST NOT** output text without a tool call. You **MUST** call submit_result to finish.
</system-reminder>
@@ -15,9 +15,9 @@ If you need additional information, you can find your conversation with the user
{{/if}} {{/if}}
{{SECTION_SEPARATOR "Closure"}} {{SECTION_SEPARATOR "Closure"}}
No TODO tracking, no progress updates. Execute, call `submit_result`, done. No TODO tracking, no progress updates. Execute, call `yield`, done.
When finished, you **MUST** call `submit_result` exactly once. This is like writing to a ticket, provide what is required, and close it. When finished, you **MUST** call `yield` exactly once. This is like writing to a ticket, provide what is required, and close it.
This is your only way to return a result. You **MUST NOT** put JSON in plain text, and you **MUST NOT** substitute a text summary for the structured `result.data` parameter. This is your only way to return a result. You **MUST NOT** put JSON in plain text, and you **MUST NOT** substitute a text summary for the structured `result.data` parameter.
@@ -29,7 +29,7 @@ Your result **MUST** match this TypeScript interface:
{{/if}} {{/if}}
{{SECTION_SEPARATOR "Giving Up"}} {{SECTION_SEPARATOR "Giving Up"}}
Giving up is a last resort. If truly blocked, you **MUST** call `submit_result` exactly once with `result.error` describing what you tried and the exact blocker. Giving up is a last resort. If truly blocked, you **MUST** call `yield` exactly once with `result.error` describing what you tried and the exact blocker.
You **MUST NOT** give up due to uncertainty, missing information obtainable via tools or repo context, or needing a design decision you can derive yourself. You **MUST NOT** give up due to uncertainty, missing information obtainable via tools or repo context, or needing a design decision you can derive yourself.
You **MUST** keep going until this ticket is closed. This matters. You **MUST** keep going until this ticket is closed. This matters.
@@ -0,0 +1,11 @@
<system-reminder>
You stopped without calling yield. This is reminder {{retryCount}} of {{maxRetries}}.
You **MUST** call yield as your only action now. Choose one:
- If task is complete: call yield with your result in `result.data`
- If task failed: call yield with `result.error` describing what happened
You **MUST NOT** give up if you can still complete the task through exploration (using available tools or repo context). If you submit an error, you **MUST** include what you tried and the exact blocker.
You **MUST NOT** output text without a tool call. You **MUST** call yield to finish.
</system-reminder>
+3 -3
View File
@@ -209,8 +209,8 @@ export interface CreateAgentSessionOptions {
/** Output schema for structured completion (subagents) */ /** Output schema for structured completion (subagents) */
outputSchema?: unknown; outputSchema?: unknown;
/** Whether to include the submit_result tool by default */ /** Whether to include the yield tool by default */
requireSubmitResultTool?: boolean; requireYieldTool?: boolean;
/** Task recursion depth (for subagent sessions). Default: 0 */ /** Task recursion depth (for subagent sessions). Default: 0 */
taskDepth?: number; taskDepth?: number;
/** Parent task ID prefix for nested artifact naming (e.g., "6-Extensions") */ /** Parent task ID prefix for nested artifact naming (e.g., "6-Extensions") */
@@ -916,7 +916,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
skills, skills,
eventBus, eventBus,
outputSchema: options.outputSchema, outputSchema: options.outputSchema,
requireSubmitResultTool: options.requireSubmitResultTool, requireYieldTool: options.requireYieldTool,
taskDepth: options.taskDepth ?? 0, taskDepth: options.taskDepth ?? 0,
getSessionFile: () => sessionManager.getSessionFile() ?? null, getSessionFile: () => sessionManager.getSessionFile() ?? null,
getPythonKernelOwnerId: () => pythonKernelOwnerId, getPythonKernelOwnerId: () => pythonKernelOwnerId,
+42 -47
View File
@@ -18,8 +18,8 @@ import { runExtensionCompact, runExtensionSetModel } from "../extensibility/exte
import type { Skill } from "../extensibility/skills"; import type { Skill } from "../extensibility/skills";
import { callTool } from "../mcp/client"; import { callTool } from "../mcp/client";
import type { MCPManager } from "../mcp/manager"; import type { MCPManager } from "../mcp/manager";
import submitReminderTemplate from "../prompts/system/subagent-submit-reminder.md" with { type: "text" };
import subagentSystemPromptTemplate from "../prompts/system/subagent-system-prompt.md" with { type: "text" }; import subagentSystemPromptTemplate from "../prompts/system/subagent-system-prompt.md" with { type: "text" };
import submitReminderTemplate from "../prompts/system/subagent-yield-reminder.md" with { type: "text" };
import { createAgentSession, discoverAuthStorage } from "../sdk"; import { createAgentSession, discoverAuthStorage } from "../sdk";
import type { AgentSession, AgentSessionEvent } from "../session/agent-session"; import type { AgentSession, AgentSessionEvent } from "../session/agent-session";
import type { AuthStorage } from "../session/auth-storage"; import type { AuthStorage } from "../session/auth-storage";
@@ -223,7 +223,7 @@ function resolveFallbackCompletion(rawOutput: string, outputSchema: unknown): {
return { data: candidate }; return { data: candidate };
} }
export interface SubmitResultItem { export interface YieldItem {
data?: unknown; data?: unknown;
status?: "success" | "aborted"; status?: "success" | "aborted";
error?: string; error?: string;
@@ -235,7 +235,7 @@ interface FinalizeSubprocessOutputArgs {
stderr: string; stderr: string;
doneAborted: boolean; doneAborted: boolean;
signalAborted: boolean; signalAborted: boolean;
submitResultItems?: SubmitResultItem[]; yieldItems?: YieldItem[];
reportFindings?: ReviewFinding[]; reportFindings?: ReviewFinding[];
outputSchema: unknown; outputSchema: unknown;
} }
@@ -244,44 +244,42 @@ interface FinalizeSubprocessOutputResult {
rawOutput: string; rawOutput: string;
exitCode: number; exitCode: number;
stderr: string; stderr: string;
abortedViaSubmitResult: boolean; abortedViaYield: boolean;
hasSubmitResult: boolean; hasYield: boolean;
} }
export const SUBAGENT_WARNING_NULL_SUBMIT_RESULT = "SYSTEM WARNING: Subagent called submit_result with null data."; export const SUBAGENT_WARNING_NULL_YIELD = "SYSTEM WARNING: Subagent called yield with null data.";
export const SUBAGENT_WARNING_MISSING_SUBMIT_RESULT = export const SUBAGENT_WARNING_MISSING_YIELD =
"SYSTEM WARNING: Subagent exited without calling submit_result tool after 3 reminders."; "SYSTEM WARNING: Subagent exited without calling yield tool after 3 reminders.";
export function finalizeSubprocessOutput(args: FinalizeSubprocessOutputArgs): FinalizeSubprocessOutputResult { export function finalizeSubprocessOutput(args: FinalizeSubprocessOutputArgs): FinalizeSubprocessOutputResult {
let { rawOutput, exitCode, stderr } = args; let { rawOutput, exitCode, stderr } = args;
const { submitResultItems, reportFindings, doneAborted, signalAborted, outputSchema } = args; const { yieldItems, reportFindings, doneAborted, signalAborted, outputSchema } = args;
let abortedViaSubmitResult = false; let abortedViaYield = false;
const hasSubmitResult = Array.isArray(submitResultItems) && submitResultItems.length > 0; const hasYield = Array.isArray(yieldItems) && yieldItems.length > 0;
if (hasSubmitResult) { if (hasYield) {
const lastSubmitResult = submitResultItems[submitResultItems.length - 1]; const lastYield = yieldItems[yieldItems.length - 1];
if (lastSubmitResult?.status === "aborted") { if (lastYield?.status === "aborted") {
abortedViaSubmitResult = true; abortedViaYield = true;
exitCode = 0; exitCode = 0;
stderr = lastSubmitResult.error || "Subagent aborted task"; stderr = lastYield.error || "Subagent aborted task";
try { try {
rawOutput = JSON.stringify({ aborted: true, error: lastSubmitResult.error }, null, 2); rawOutput = JSON.stringify({ aborted: true, error: lastYield.error }, null, 2);
} catch { } catch {
rawOutput = `{"aborted":true,"error":"${lastSubmitResult.error || "Unknown error"}"}`; rawOutput = `{"aborted":true,"error":"${lastYield.error || "Unknown error"}"}`;
} }
} else { } else {
const submitData = lastSubmitResult?.data; const submitData = lastYield?.data;
if (submitData === null || submitData === undefined) { if (submitData === null || submitData === undefined) {
rawOutput = rawOutput rawOutput = rawOutput ? `${SUBAGENT_WARNING_NULL_YIELD}\n\n${rawOutput}` : SUBAGENT_WARNING_NULL_YIELD;
? `${SUBAGENT_WARNING_NULL_SUBMIT_RESULT}\n\n${rawOutput}`
: SUBAGENT_WARNING_NULL_SUBMIT_RESULT;
} else { } else {
const completeData = normalizeCompleteData(submitData, reportFindings); const completeData = normalizeCompleteData(submitData, reportFindings);
try { try {
rawOutput = JSON.stringify(completeData, null, 2) ?? "null"; rawOutput = JSON.stringify(completeData, null, 2) ?? "null";
} catch (err) { } catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err); const errorMessage = err instanceof Error ? err.message : String(err);
rawOutput = `{"error":"Failed to serialize submit_result data: ${errorMessage}"}`; rawOutput = `{"error":"Failed to serialize yield data: ${errorMessage}"}`;
} }
exitCode = 0; exitCode = 0;
stderr = ""; stderr = "";
@@ -307,17 +305,15 @@ export function finalizeSubprocessOutput(args: FinalizeSubprocessOutputArgs): Fi
stderr = ""; stderr = "";
} else if (exitCode === 0) { } else if (exitCode === 0) {
const hasRawOutput = rawOutput.trim().length > 0; const hasRawOutput = rawOutput.trim().length > 0;
rawOutput = rawOutput rawOutput = rawOutput ? `${SUBAGENT_WARNING_MISSING_YIELD}\n\n${rawOutput}` : SUBAGENT_WARNING_MISSING_YIELD;
? `${SUBAGENT_WARNING_MISSING_SUBMIT_RESULT}\n\n${rawOutput}`
: SUBAGENT_WARNING_MISSING_SUBMIT_RESULT;
if (hasOutputSchema || !hasRawOutput) { if (hasOutputSchema || !hasRawOutput) {
exitCode = 1; exitCode = 1;
stderr = SUBAGENT_WARNING_MISSING_SUBMIT_RESULT; stderr = SUBAGENT_WARNING_MISSING_YIELD;
} }
} }
} }
return { rawOutput, exitCode, stderr, abortedViaSubmitResult, hasSubmitResult }; return { rawOutput, exitCode, stderr, abortedViaYield, hasYield };
} }
/** /**
@@ -564,7 +560,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
const abortSignal = abortController.signal; const abortSignal = abortController.signal;
let activeSession: AgentSession | null = null; let activeSession: AgentSession | null = null;
let unsubscribe: (() => void) | null = null; let unsubscribe: (() => void) | null = null;
let submitResultCalled = false; let yieldCalled = false;
// Accumulate usage incrementally from message_end events (no memory for streaming events) // Accumulate usage incrementally from message_end events (no memory for streaming events)
const accumulatedUsage = { const accumulatedUsage = {
@@ -789,8 +785,8 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
existing.push(data); existing.push(data);
} }
progress.extractedToolData[event.toolName] = existing; progress.extractedToolData[event.toolName] = existing;
if (event.toolName === "submit_result") { if (event.toolName === "yield") {
submitResultCalled = true; yieldCalled = true;
} }
} }
} }
@@ -955,7 +951,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
thinkingLevel: effectiveThinkingLevel, thinkingLevel: effectiveThinkingLevel,
toolNames, toolNames,
outputSchema, outputSchema,
requireSubmitResultTool: true, requireYieldTool: true,
contextFiles: options.contextFiles, contextFiles: options.contextFiles,
skills: options.skills, skills: options.skills,
promptTemplates: options.promptTemplates, promptTemplates: options.promptTemplates,
@@ -1070,7 +1066,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
await extensionRunner.emit({ type: "session_start" }); await extensionRunner.emit({ type: "session_start" });
} }
const MAX_SUBMIT_RESULT_RETRIES = 3; const MAX_YIELD_RETRIES = 3;
unsubscribe = session.subscribe(event => { unsubscribe = session.subscribe(event => {
if (isAgentEvent(event)) { if (isAgentEvent(event)) {
try { try {
@@ -1087,15 +1083,15 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
await session.prompt(task, { attribution: "agent" }); await session.prompt(task, { attribution: "agent" });
await session.waitForIdle(); await session.waitForIdle();
const reminderToolChoice = buildNamedToolChoice("submit_result", session.model); const reminderToolChoice = buildNamedToolChoice("yield", session.model);
let retryCount = 0; let retryCount = 0;
while (!submitResultCalled && retryCount < MAX_SUBMIT_RESULT_RETRIES && !abortSignal.aborted) { while (!yieldCalled && retryCount < MAX_YIELD_RETRIES && !abortSignal.aborted) {
try { try {
retryCount++; retryCount++;
const reminder = prompt.render(submitReminderTemplate, { const reminder = prompt.render(submitReminderTemplate, {
retryCount, retryCount,
maxRetries: MAX_SUBMIT_RESULT_RETRIES, maxRetries: MAX_YIELD_RETRIES,
}); });
await session.prompt(reminder, { await session.prompt(reminder, {
@@ -1111,7 +1107,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
} }
await session.waitForIdle(); await session.waitForIdle();
if (!submitResultCalled && !abortSignal.aborted) { if (!yieldCalled && !abortSignal.aborted) {
exitCode = 0; exitCode = 0;
} }
@@ -1186,7 +1182,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
// Use final output if available, otherwise accumulated output // Use final output if available, otherwise accumulated output
let rawOutput = finalOutputChunks.length > 0 ? finalOutputChunks.join("") : outputChunks.join(""); let rawOutput = finalOutputChunks.length > 0 ? finalOutputChunks.join("") : outputChunks.join("");
const submitResultItems = progress.extractedToolData?.submit_result as SubmitResultItem[] | undefined; const yieldItems = progress.extractedToolData?.yield as YieldItem[] | undefined;
const reportFindings = progress.extractedToolData?.report_finding as ReviewFinding[] | undefined; const reportFindings = progress.extractedToolData?.report_finding as ReviewFinding[] | undefined;
const finalized = finalizeSubprocessOutput({ const finalized = finalizeSubprocessOutput({
rawOutput, rawOutput,
@@ -1194,17 +1190,16 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
stderr, stderr,
doneAborted: Boolean(done.aborted), doneAborted: Boolean(done.aborted),
signalAborted: Boolean(signal?.aborted), signalAborted: Boolean(signal?.aborted),
submitResultItems, yieldItems,
reportFindings, reportFindings,
outputSchema, outputSchema,
}); });
rawOutput = finalized.rawOutput; rawOutput = finalized.rawOutput;
exitCode = finalized.exitCode; exitCode = finalized.exitCode;
stderr = finalized.stderr; stderr = finalized.stderr;
const lastSubmitResult = submitResultItems?.[submitResultItems.length - 1]; const lastYield = yieldItems?.[yieldItems.length - 1];
const submitResultAbortReason = const yieldAbortReason = lastYield?.status === "aborted" ? lastYield.error || "Subagent aborted task" : undefined;
lastSubmitResult?.status === "aborted" ? lastSubmitResult.error || "Subagent aborted task" : undefined; const { abortedViaYield, hasYield } = finalized;
const { abortedViaSubmitResult, hasSubmitResult } = finalized;
const { content: truncatedOutput, truncated } = truncateTail(rawOutput, { const { content: truncatedOutput, truncated } = truncateTail(rawOutput, {
maxBytes: MAX_OUTPUT_BYTES, maxBytes: MAX_OUTPUT_BYTES,
maxLines: MAX_OUTPUT_LINES, maxLines: MAX_OUTPUT_LINES,
@@ -1228,16 +1223,16 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
} }
// Update final progress // Update final progress
const wasAborted = abortedViaSubmitResult || (!hasSubmitResult && (done.aborted || signal?.aborted || false)); const wasAborted = abortedViaYield || (!hasYield && (done.aborted || signal?.aborted || false));
const finalAbortReason = wasAborted const finalAbortReason = wasAborted
? abortedViaSubmitResult ? abortedViaYield
? submitResultAbortReason ? yieldAbortReason
: (done.abortReason ?? (signal?.aborted ? resolveSignalAbortReason() : "Subagent aborted task")) : (done.abortReason ?? (signal?.aborted ? resolveSignalAbortReason() : "Subagent aborted task"))
: undefined; : undefined;
progress.status = wasAborted ? "aborted" : exitCode === 0 ? "completed" : "failed"; progress.status = wasAborted ? "aborted" : exitCode === 0 ? "completed" : "failed";
scheduleProgress(true); scheduleProgress(true);
// Emit lifecycle end event after finalization so submit_result status is reflected // Emit lifecycle end event after finalization so yield status is reflected
if (options.eventBus) { if (options.eventBus) {
options.eventBus.emit(TASK_SUBAGENT_LIFECYCLE_CHANNEL, { options.eventBus.emit(TASK_SUBAGENT_LIFECYCLE_CHANNEL, {
id, id,
+11 -13
View File
@@ -101,12 +101,12 @@ function formatTaskId(id: string): string {
return `${indices} ${labels}`; return `${indices} ${labels}`;
} }
const MISSING_SUBMIT_RESULT_WARNING_PREFIX = "SYSTEM WARNING: Subagent exited without calling submit_result tool"; const MISSING_YIELD_WARNING_PREFIX = "SYSTEM WARNING: Subagent exited without calling yield tool";
function extractMissingSubmitResultWarning(output: string): { warning?: string; rest: string } { function extractMissingYieldWarning(output: string): { warning?: string; rest: string } {
const lines = output.split("\n"); const lines = output.split("\n");
const firstLine = lines[0]?.trim() ?? ""; const firstLine = lines[0]?.trim() ?? "";
if (!firstLine.startsWith(MISSING_SUBMIT_RESULT_WARNING_PREFIX)) { if (!firstLine.startsWith(MISSING_YIELD_WARNING_PREFIX)) {
return { rest: output }; return { rest: output };
} }
const rest = lines const rest = lines
@@ -572,9 +572,9 @@ function renderAgentProgress(
// Render extracted tool data inline (e.g., review findings) // Render extracted tool data inline (e.g., review findings)
if (progress.extractedToolData) { if (progress.extractedToolData) {
// For completed tasks, check for review verdict from submit_result tool // For completed tasks, check for review verdict from yield tool
if (progress.status === "completed") { if (progress.status === "completed") {
const completeData = progress.extractedToolData.submit_result as Array<{ data: unknown }> | undefined; const completeData = progress.extractedToolData.yield as Array<{ data: unknown }> | undefined;
const reportFindingData = normalizeReportFindings(progress.extractedToolData.report_finding); const reportFindingData = normalizeReportFindings(progress.extractedToolData.report_finding);
const reviewData = completeData const reviewData = completeData
?.map(c => c.data as SubmitReviewDetails) ?.map(c => c.data as SubmitReviewDetails)
@@ -731,9 +731,7 @@ function renderAgentResult(result: SingleResult, isLast: boolean, expanded: bool
const prefix = isLast ? theme.fg("dim", theme.tree.last) : theme.fg("dim", theme.tree.branch); const prefix = isLast ? theme.fg("dim", theme.tree.last) : theme.fg("dim", theme.tree.branch);
const continuePrefix = isLast ? " " : `${theme.fg("dim", theme.tree.vertical)} `; const continuePrefix = isLast ? " " : `${theme.fg("dim", theme.tree.vertical)} `;
const { warning: missingCompleteWarning, rest: outputWithoutWarning } = extractMissingSubmitResultWarning( const { warning: missingCompleteWarning, rest: outputWithoutWarning } = extractMissingYieldWarning(result.output);
result.output,
);
const aborted = result.aborted ?? false; const aborted = result.aborted ?? false;
const mergeFailed = !aborted && result.exitCode === 0 && !!result.error; const mergeFailed = !aborted && result.exitCode === 0 && !!result.error;
const success = !aborted && result.exitCode === 0 && !result.error; const success = !aborted && result.exitCode === 0 && !result.error;
@@ -783,11 +781,11 @@ function renderAgentResult(result: SingleResult, isLast: boolean, expanded: bool
`${continuePrefix}${theme.fg("error", theme.status.aborted)} ${theme.fg("dim", truncateToWidth(replaceTabs(result.abortReason), 80))}`, `${continuePrefix}${theme.fg("error", theme.status.aborted)} ${theme.fg("dim", truncateToWidth(replaceTabs(result.abortReason), 80))}`,
); );
} }
// Check for review result (submit_result with review schema + report_finding) // Check for review result (yield with review schema + report_finding)
const completeData = result.extractedToolData?.submit_result as Array<{ data: unknown }> | undefined; const completeData = result.extractedToolData?.yield as Array<{ data: unknown }> | undefined;
const reportFindingData = normalizeReportFindings(result.extractedToolData?.report_finding); const reportFindingData = normalizeReportFindings(result.extractedToolData?.report_finding);
// Extract review verdict from submit_result tool's data field if it matches SubmitReviewDetails // Extract review verdict from yield tool's data field if it matches SubmitReviewDetails
const reviewData = completeData const reviewData = completeData
?.map(c => c.data as SubmitReviewDetails) ?.map(c => c.data as SubmitReviewDetails)
.filter(d => d && typeof d === "object" && "overall_correctness" in d); .filter(d => d && typeof d === "object" && "overall_correctness" in d);
@@ -804,7 +802,7 @@ function renderAgentResult(result: SingleResult, isLast: boolean, expanded: bool
const hasCompleteData = completeData && completeData.length > 0; const hasCompleteData = completeData && completeData.length > 0;
const message = hasCompleteData const message = hasCompleteData
? "Review verdict missing expected fields" ? "Review verdict missing expected fields"
: "Review incomplete (submit_result not called)"; : "Review incomplete (yield not called)";
lines.push(`${continuePrefix}${theme.fg("warning", theme.status.warning)} ${theme.fg("dim", message)}`); lines.push(`${continuePrefix}${theme.fg("warning", theme.status.warning)} ${theme.fg("dim", message)}`);
lines.push(`${continuePrefix}${formatFindingSummary(reportFindingData, theme)}`); lines.push(`${continuePrefix}${formatFindingSummary(reportFindingData, theme)}`);
lines.push(...renderFindings(reportFindingData, continuePrefix, expanded, theme)); lines.push(...renderFindings(reportFindingData, continuePrefix, expanded, theme));
@@ -817,7 +815,7 @@ function renderAgentResult(result: SingleResult, isLast: boolean, expanded: bool
if (result.extractedToolData) { if (result.extractedToolData) {
for (const [toolName, dataArray] of Object.entries(result.extractedToolData)) { for (const [toolName, dataArray] of Object.entries(result.extractedToolData)) {
// Skip review tools - handled above // Skip review tools - handled above
if (toolName === "submit_result" || toolName === "report_finding") continue; if (toolName === "yield" || toolName === "report_finding") continue;
const handler = subprocessToolRegistry.getHandler(toolName); const handler = subprocessToolRegistry.getHandler(toolName);
if (handler?.renderFinal && (dataArray as unknown[]).length > 0) { if (handler?.renderFinal && (dataArray as unknown[]).length > 0) {
+10 -10
View File
@@ -53,9 +53,9 @@ import { ResolveTool } from "./resolve";
import { reportFindingTool } from "./review"; import { reportFindingTool } from "./review";
import { SearchToolBm25Tool } from "./search-tool-bm25"; import { SearchToolBm25Tool } from "./search-tool-bm25";
import { loadSshTool } from "./ssh"; import { loadSshTool } from "./ssh";
import { SubmitResultTool } from "./submit-result";
import { type TodoPhase, TodoWriteTool } from "./todo-write"; import { type TodoPhase, TodoWriteTool } from "./todo-write";
import { WriteTool } from "./write"; import { WriteTool } from "./write";
import { YieldTool } from "./yield";
// Exa MCP tools (22 tools) // Exa MCP tools (22 tools)
@@ -91,10 +91,10 @@ export * from "./resolve";
export * from "./review"; export * from "./review";
export * from "./search-tool-bm25"; export * from "./search-tool-bm25";
export * from "./ssh"; export * from "./ssh";
export * from "./submit-result";
export * from "./todo-write"; export * from "./todo-write";
export * from "./vim"; export * from "./vim";
export * from "./write"; export * from "./write";
export * from "./yield";
/** Tool type (AgentTool from pi-ai) */ /** Tool type (AgentTool from pi-ai) */
export type Tool = AgentTool<any, any, any>; export type Tool = AgentTool<any, any, any>;
@@ -131,8 +131,8 @@ export interface ToolSession {
eventBus?: EventBus; eventBus?: EventBus;
/** Output schema for structured completion (subagents) */ /** Output schema for structured completion (subagents) */
outputSchema?: unknown; outputSchema?: unknown;
/** Whether to include the submit_result tool by default */ /** Whether to include the yield tool by default */
requireSubmitResultTool?: boolean; requireYieldTool?: boolean;
/** Task recursion depth (0 = top-level, 1 = first child, etc.) */ /** Task recursion depth (0 = top-level, 1 = first child, etc.) */
taskDepth?: number; taskDepth?: number;
/** Get session file */ /** Get session file */
@@ -245,7 +245,7 @@ export const BUILTIN_TOOLS: Record<string, ToolFactory> = {
}; };
export const HIDDEN_TOOLS: Record<string, ToolFactory> = { export const HIDDEN_TOOLS: Record<string, ToolFactory> = {
submit_result: s => new SubmitResultTool(s), yield: s => new YieldTool(s),
report_finding: () => reportFindingTool, report_finding: () => reportFindingTool,
report_tool_issue: s => createReportToolIssueTool(s), report_tool_issue: s => createReportToolIssueTool(s),
exit_plan_mode: s => new ExitPlanModeTool(s), exit_plan_mode: s => new ExitPlanModeTool(s),
@@ -288,7 +288,7 @@ function getPythonModeFromEnv(): PythonToolMode | null {
* Create tools from BUILTIN_TOOLS registry. * Create tools from BUILTIN_TOOLS registry.
*/ */
export async function createTools(session: ToolSession, toolNames?: string[]): Promise<Tool[]> { export async function createTools(session: ToolSession, toolNames?: string[]): Promise<Tool[]> {
const includeSubmitResult = session.requireSubmitResultTool === true; const includeYield = session.requireYieldTool === true;
const enableLsp = session.enableLsp ?? true; const enableLsp = session.enableLsp ?? true;
const requestedTools = const requestedTools =
toolNames && toolNames.length > 0 ? [...new Set(toolNames.map(name => name.toLowerCase()))] : undefined; toolNames && toolNames.length > 0 ? [...new Set(toolNames.map(name => name.toLowerCase()))] : undefined;
@@ -390,7 +390,7 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
if (name === "bash") return allowBash; if (name === "bash") return allowBash;
if (name === "python") return allowPython; if (name === "python") return allowPython;
if (name === "debug") return session.settings.get("debug.enabled"); if (name === "debug") return session.settings.get("debug.enabled");
if (name === "todo_write") return !includeSubmitResult && session.settings.get("todo.enabled"); if (name === "todo_write") return !includeYield && session.settings.get("todo.enabled");
if (name === "find") return session.settings.get("find.enabled"); if (name === "find") return session.settings.get("find.enabled");
if (name === "grep") return session.settings.get("grep.enabled"); if (name === "grep") return session.settings.get("grep.enabled");
if (name.startsWith("gh_")) return session.settings.get("github.enabled"); if (name.startsWith("gh_")) return session.settings.get("github.enabled");
@@ -411,8 +411,8 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
} }
return true; return true;
}; };
if (includeSubmitResult && requestedTools && !requestedTools.includes("submit_result")) { if (includeYield && requestedTools && !requestedTools.includes("yield")) {
requestedTools.push("submit_result"); requestedTools.push("yield");
} }
const filteredRequestedTools = requestedTools?.filter(name => name in allTools && isToolAllowed(name)); const filteredRequestedTools = requestedTools?.filter(name => name in allTools && isToolAllowed(name));
@@ -421,7 +421,7 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
? filteredRequestedTools.filter(name => name !== "resolve").map(name => [name, allTools[name]] as const) ? filteredRequestedTools.filter(name => name !== "resolve").map(name => [name, allTools[name]] as const)
: [ : [
...Object.entries(BUILTIN_TOOLS).filter(([name]) => isToolAllowed(name)), ...Object.entries(BUILTIN_TOOLS).filter(([name]) => isToolAllowed(name)),
...(includeSubmitResult ? ([["submit_result", HIDDEN_TOOLS.submit_result]] as const) : []), ...(includeYield ? ([["yield", HIDDEN_TOOLS.yield]] as const) : []),
...([["exit_plan_mode", HIDDEN_TOOLS.exit_plan_mode]] as const), ...([["exit_plan_mode", HIDDEN_TOOLS.exit_plan_mode]] as const),
]; ];
+3 -3
View File
@@ -3,7 +3,7 @@
* *
* Used by the reviewer agent to report findings in a structured way. * Used by the reviewer agent to report findings in a structured way.
* Hidden by default - only enabled when explicitly listed in agent's tools. * Hidden by default - only enabled when explicitly listed in agent's tools.
* Reviewers finish via `submit_result` tool with SubmitReviewDetails schema. * Reviewers finish via `yield` tool with SubmitReviewDetails schema.
*/ */
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// Subprocess tool handlers - registered for extraction/rendering in task tool // Subprocess tool handlers - registered for extraction/rendering in task tool
@@ -131,7 +131,7 @@ export function parseReportFindingDetails(value: unknown): ReportFindingDetails
export const reportFindingTool: AgentTool<typeof ReportFindingParams, ReportFindingDetails, Theme> = { export const reportFindingTool: AgentTool<typeof ReportFindingParams, ReportFindingDetails, Theme> = {
name: "report_finding", name: "report_finding",
label: "Report Finding", label: "Report Finding",
description: "Report a code review finding. Use this for each issue found. Call submit_result when done.", description: "Report a code review finding. Use this for each issue found. Call yield when done.",
parameters: ReportFindingParams, parameters: ReportFindingParams,
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) { async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
const { title, body, priority, confidence, file_path, line_start, line_end } = params; const { title, body, priority, confidence, file_path, line_start, line_end } = params;
@@ -186,7 +186,7 @@ export const reportFindingTool: AgentTool<typeof ReportFindingParams, ReportFind
}, },
}; };
/** SubmitReviewDetails - used for rendering review results from submit_result tool */ /** SubmitReviewDetails - used for rendering review results from yield tool */
export interface SubmitReviewDetails { export interface SubmitReviewDetails {
overall_correctness: "correct" | "incorrect"; overall_correctness: "correct" | "incorrect";
explanation: string; explanation: string;
@@ -12,7 +12,7 @@ import { subprocessToolRegistry } from "../task/subprocess-tool-registry";
import type { ToolSession } from "."; import type { ToolSession } from ".";
import { jtdToJsonSchema, normalizeSchema } from "./jtd-to-json-schema"; import { jtdToJsonSchema, normalizeSchema } from "./jtd-to-json-schema";
export interface SubmitResultDetails { export interface YieldDetails {
data: unknown; data: unknown;
status: "success" | "aborted"; status: "success" | "aborted";
error?: string; error?: string;
@@ -40,8 +40,8 @@ function formatAjvErrors(errors: ErrorObject[] | null | undefined): string {
.join("; "); .join("; ");
} }
export class SubmitResultTool implements AgentTool<TSchema, SubmitResultDetails> { export class YieldTool implements AgentTool<TSchema, YieldDetails> {
readonly name = "submit_result"; readonly name = "yield";
readonly label = "Submit Result"; readonly label = "Submit Result";
readonly description = readonly description =
"Finish the task with structured JSON output. Call exactly once at the end of the task.\n\n" + "Finish the task with structured JSON output. Call exactly once at the end of the task.\n\n" +
@@ -143,9 +143,9 @@ export class SubmitResultTool implements AgentTool<TSchema, SubmitResultDetails>
_toolCallId: string, _toolCallId: string,
params: Static<TSchema>, params: Static<TSchema>,
_signal?: AbortSignal, _signal?: AbortSignal,
_onUpdate?: AgentToolUpdateCallback<SubmitResultDetails>, _onUpdate?: AgentToolUpdateCallback<YieldDetails>,
_context?: AgentToolContext, _context?: AgentToolContext,
): Promise<AgentToolResult<SubmitResultDetails>> { ): Promise<AgentToolResult<YieldDetails>> {
const raw = params as Record<string, unknown>; const raw = params as Record<string, unknown>;
const rawResult = raw.result; const rawResult = raw.result;
if (!rawResult || typeof rawResult !== "object" || Array.isArray(rawResult)) { if (!rawResult || typeof rawResult !== "object" || Array.isArray(rawResult)) {
@@ -169,7 +169,7 @@ export class SubmitResultTool implements AgentTool<TSchema, SubmitResultDetails>
let schemaValidationOverridden = false; let schemaValidationOverridden = false;
if (status === "success") { if (status === "success") {
if (data === undefined || data === null) { if (data === undefined || data === null) {
throw new Error("data is required when submit_result indicates success"); throw new Error("data is required when yield indicates success");
} }
if (this.#validate && !this.#validate(data)) { if (this.#validate && !this.#validate(data)) {
this.#schemaValidationFailures++; this.#schemaValidationFailures++;
@@ -194,7 +194,7 @@ export class SubmitResultTool implements AgentTool<TSchema, SubmitResultDetails>
} }
// Register subprocess tool handler for extraction + termination. // Register subprocess tool handler for extraction + termination.
subprocessToolRegistry.register<SubmitResultDetails>("submit_result", { subprocessToolRegistry.register<YieldDetails>("yield", {
extractData: event => { extractData: event => {
const details = event.result?.details; const details = event.result?.details;
if (!details || typeof details !== "object") return undefined; if (!details || typeof details !== "object") return undefined;
@@ -64,6 +64,6 @@ describe("parseAgentFields", () => {
tools: ["Read", "Grep"], tools: ["Read", "Grep"],
}); });
expect(fields?.tools).toEqual(["read", "grep", "submit_result"]); expect(fields?.tools).toEqual(["read", "grep", "yield"]);
}); });
}); });
@@ -234,7 +234,6 @@ describe("ModelRegistry runtime provider registration", () => {
expect(model?.api).toBe("openai-completions"); expect(model?.api).toBe("openai-completions");
}); });
test("headers-only runtime override preserves existing baseUrl across refresh", async () => { test("headers-only runtime override preserves existing baseUrl across refresh", async () => {
const registry = new ModelRegistry(authStorage, modelsJsonPath); const registry = new ModelRegistry(authStorage, modelsJsonPath);
const modelId = "runtime-headers-only-baseurl-survivor"; const modelId = "runtime-headers-only-baseurl-survivor";
@@ -292,11 +291,7 @@ describe("ModelRegistry runtime provider registration", () => {
const registry = new ModelRegistry(authStorage, modelsJsonPath); const registry = new ModelRegistry(authStorage, modelsJsonPath);
expect(registry.find("anthropic", modelId)?.headers?.[sharedHeader]).toBe(configHeaderValue); expect(registry.find("anthropic", modelId)?.headers?.[sharedHeader]).toBe(configHeaderValue);
registry.registerProvider( registry.registerProvider("anthropic", { headers: { [sharedHeader]: runtimeHeaderValue } }, "ext://runtime");
"anthropic",
{ headers: { [sharedHeader]: runtimeHeaderValue } },
"ext://runtime",
);
await expectProviderHeaderAcrossRefresh(registry, "anthropic", sharedHeader, runtimeHeaderValue); await expectProviderHeaderAcrossRefresh(registry, "anthropic", sharedHeader, runtimeHeaderValue);
registry.clearSourceRegistrations("ext://runtime"); registry.clearSourceRegistrations("ext://runtime");
@@ -433,18 +428,10 @@ describe("ModelRegistry runtime provider registration", () => {
const sourceAHeader = "X-Source-A-Header"; const sourceAHeader = "X-Source-A-Header";
const sourceBHeader = "X-Source-B-Header"; const sourceBHeader = "X-Source-B-Header";
registry.registerProvider( registry.registerProvider(providerName, { headers: { [sourceAHeader]: "from-source-a" } }, "ext://a");
providerName,
{ headers: { [sourceAHeader]: "from-source-a" } },
"ext://a",
);
expectProviderHeader(registry, providerName, sourceAHeader, "from-source-a"); expectProviderHeader(registry, providerName, sourceAHeader, "from-source-a");
registry.registerProvider( registry.registerProvider(providerName, { headers: { [sourceBHeader]: "from-source-b" } }, "ext://b");
providerName,
{ headers: { [sourceBHeader]: "from-source-b" } },
"ext://b",
);
await expectProviderHeaderAcrossRefresh(registry, providerName, sourceAHeader, undefined); await expectProviderHeaderAcrossRefresh(registry, providerName, sourceAHeader, undefined);
expectProviderHeader(registry, providerName, sourceBHeader, "from-source-b"); expectProviderHeader(registry, providerName, sourceBHeader, "from-source-b");
}); });
@@ -6,7 +6,7 @@ import type { CreateAgentSessionResult } from "../../src/sdk";
import * as sdkModule from "../../src/sdk"; import * as sdkModule from "../../src/sdk";
import type { AgentSession, AgentSessionEvent, PromptOptions } from "../../src/session/agent-session"; import type { AgentSession, AgentSessionEvent, PromptOptions } from "../../src/session/agent-session";
import type { AuthStorage } from "../../src/session/auth-storage"; import type { AuthStorage } from "../../src/session/auth-storage";
import { runSubprocess, SUBAGENT_WARNING_MISSING_SUBMIT_RESULT } from "../../src/task/executor"; import { runSubprocess, SUBAGENT_WARNING_MISSING_YIELD } from "../../src/task/executor";
import type { AgentDefinition } from "../../src/task/types"; import type { AgentDefinition } from "../../src/task/types";
import { EventBus } from "../../src/utils/event-bus"; import { EventBus } from "../../src/utils/event-bus";
@@ -55,7 +55,7 @@ function createMockSession(
sessionManager: { sessionManager: {
appendSessionInit: () => {}, appendSessionInit: () => {},
}, },
getActiveToolNames: () => ["read", "submit_result"], getActiveToolNames: () => ["read", "yield"],
setActiveToolsByName: async (_toolNames: string[]) => {}, setActiveToolsByName: async (_toolNames: string[]) => {},
subscribe: (listener: (event: AgentSessionEvent) => void) => { subscribe: (listener: (event: AgentSessionEvent) => void) => {
listeners.push(listener); listeners.push(listener);
@@ -90,7 +90,7 @@ function mockCreateAgentSession(session: AgentSession) {
return vi.spyOn(sdkModule, "createAgentSession").mockResolvedValue(createSessionResult(session)); return vi.spyOn(sdkModule, "createAgentSession").mockResolvedValue(createSessionResult(session));
} }
describe("runSubprocess submit_result reminders", () => { describe("runSubprocess yield reminders", () => {
afterEach(() => { afterEach(() => {
vi.restoreAllMocks(); vi.restoreAllMocks();
}); });
@@ -114,7 +114,7 @@ describe("runSubprocess submit_result reminders", () => {
enableLsp: false, enableLsp: false,
}; };
it("sends reminder prompt when subagent stops without submit_result", async () => { it("sends reminder prompt when subagent stops without yield", async () => {
const prompts: string[] = []; const prompts: string[] = [];
const promptOptions: Array<PromptOptions | undefined> = []; const promptOptions: Array<PromptOptions | undefined> = [];
const session = createMockSession(({ text, options, promptIndex, emit, state }) => { const session = createMockSession(({ text, options, promptIndex, emit, state }) => {
@@ -129,7 +129,7 @@ describe("runSubprocess submit_result reminders", () => {
emit({ emit({
type: "tool_execution_end", type: "tool_execution_end",
toolCallId: "tool-1", toolCallId: "tool-1",
toolName: "submit_result", toolName: "yield",
result: { result: {
content: [{ type: "text", text: "Result submitted." }], content: [{ type: "text", text: "Result submitted." }],
details: { status: "success", data: { done: true } }, details: { status: "success", data: { done: true } },
@@ -145,12 +145,12 @@ describe("runSubprocess submit_result reminders", () => {
expect(promptOptions).toHaveLength(2); expect(promptOptions).toHaveLength(2);
expect(promptOptions[0]?.attribution).toBe("agent"); expect(promptOptions[0]?.attribution).toBe("agent");
expect(promptOptions[1]?.attribution).toBe("agent"); expect(promptOptions[1]?.attribution).toBe("agent");
expect(prompts[1]).toContain("You stopped without calling submit_result"); expect(prompts[1]).toContain("You stopped without calling yield");
expect(result.output).toContain('"done": true'); expect(result.output).toContain('"done": true');
expect(result.output.includes("SYSTEM WARNING")).toBe(false); expect(result.output.includes("SYSTEM WARNING")).toBe(false);
}); });
it("keeps null submit_result warning when subagent submits success without data", async () => { it("keeps null yield warning when subagent submits success without data", async () => {
const session = createMockSession(({ promptIndex, emit, state }) => { const session = createMockSession(({ promptIndex, emit, state }) => {
if (promptIndex === 1) { if (promptIndex === 1) {
const assistant = createAssistantStopMessage("partial output"); const assistant = createAssistantStopMessage("partial output");
@@ -161,7 +161,7 @@ describe("runSubprocess submit_result reminders", () => {
emit({ emit({
type: "tool_execution_end", type: "tool_execution_end",
toolCallId: "tool-2", toolCallId: "tool-2",
toolName: "submit_result", toolName: "yield",
result: { result: {
content: [{ type: "text", text: "Result submitted." }], content: [{ type: "text", text: "Result submitted." }],
details: { status: "success" }, details: { status: "success" },
@@ -173,21 +173,21 @@ describe("runSubprocess submit_result reminders", () => {
mockCreateAgentSession(session); mockCreateAgentSession(session);
const result = await runSubprocess({ ...baseOptions, id: "subagent-2" }); const result = await runSubprocess({ ...baseOptions, id: "subagent-2" });
expect(result.output).toContain("SYSTEM WARNING: Subagent called submit_result with null data."); expect(result.output).toContain("SYSTEM WARNING: Subagent called yield with null data.");
}); });
it("retries when submit_result tool returns an error before succeeding", async () => { it("retries when yield tool returns an error before succeeding", async () => {
const prompts: string[] = []; const prompts: string[] = [];
const session = createMockSession(({ text, promptIndex, emit, state }) => { const session = createMockSession(({ text, promptIndex, emit, state }) => {
prompts.push(text); prompts.push(text);
if (promptIndex === 1) { if (promptIndex === 1) {
const assistant = createAssistantStopMessage("attempted submit_result"); const assistant = createAssistantStopMessage("attempted yield");
state.messages.push(assistant); state.messages.push(assistant);
emit({ type: "message_end", message: assistant }); emit({ type: "message_end", message: assistant });
emit({ emit({
type: "tool_execution_end", type: "tool_execution_end",
toolCallId: "tool-error", toolCallId: "tool-error",
toolName: "submit_result", toolName: "yield",
result: { result: {
content: [{ type: "text", text: "Output does not match schema" }], content: [{ type: "text", text: "Output does not match schema" }],
details: { status: "error", error: "Output does not match schema" }, details: { status: "error", error: "Output does not match schema" },
@@ -199,7 +199,7 @@ describe("runSubprocess submit_result reminders", () => {
emit({ emit({
type: "tool_execution_end", type: "tool_execution_end",
toolCallId: "tool-success", toolCallId: "tool-success",
toolName: "submit_result", toolName: "yield",
result: { result: {
content: [{ type: "text", text: "Result submitted." }], content: [{ type: "text", text: "Result submitted." }],
details: { status: "success", data: { ok: true } }, details: { status: "success", data: { ok: true } },
@@ -221,7 +221,7 @@ describe("runSubprocess submit_result reminders", () => {
emit({ emit({
type: "tool_execution_end", type: "tool_execution_end",
toolCallId: "tool-thinking-fallback", toolCallId: "tool-thinking-fallback",
toolName: "submit_result", toolName: "yield",
result: { result: {
content: [{ type: "text", text: "Result submitted." }], content: [{ type: "text", text: "Result submitted." }],
details: { status: "success", data: { ok: true } }, details: { status: "success", data: { ok: true } },
@@ -268,7 +268,7 @@ describe("runSubprocess submit_result reminders", () => {
emit({ emit({
type: "tool_execution_end", type: "tool_execution_end",
toolCallId: `tool-thinking-override-${index}`, toolCallId: `tool-thinking-override-${index}`,
toolName: "submit_result", toolName: "yield",
result: { result: {
content: [{ type: "text", text: "Result submitted." }], content: [{ type: "text", text: "Result submitted." }],
details: { status: "success", data: { ok: true } }, details: { status: "success", data: { ok: true } },
@@ -292,11 +292,11 @@ describe("runSubprocess submit_result reminders", () => {
expect(createAgentSessionSpy.mock.calls[0]?.[0]?.thinkingLevel).toBe(cases[0].expectedThinkingLevel); expect(createAgentSessionSpy.mock.calls[0]?.[0]?.thinkingLevel).toBe(cases[0].expectedThinkingLevel);
expect(createAgentSessionSpy.mock.calls[1]?.[0]?.thinkingLevel).toBe(cases[1].expectedThinkingLevel); expect(createAgentSessionSpy.mock.calls[1]?.[0]?.thinkingLevel).toBe(cases[1].expectedThinkingLevel);
}); });
it("fails after 3 reminders when submit_result is never called for a structured task", async () => { it("fails after 3 reminders when yield is never called for a structured task", async () => {
const prompts: string[] = []; const prompts: string[] = [];
const session = createMockSession(({ text, promptIndex, emit, state }) => { const session = createMockSession(({ text, promptIndex, emit, state }) => {
prompts.push(text); prompts.push(text);
const assistant = createAssistantStopMessage(promptIndex === 1 ? "did work" : "still no submit_result"); const assistant = createAssistantStopMessage(promptIndex === 1 ? "did work" : "still no yield");
state.messages.push(assistant); state.messages.push(assistant);
emit({ type: "message_end", message: assistant }); emit({ type: "message_end", message: assistant });
}); });
@@ -311,11 +311,11 @@ describe("runSubprocess submit_result reminders", () => {
expect(prompts).toHaveLength(4); expect(prompts).toHaveLength(4);
expect(result.exitCode).toBe(1); expect(result.exitCode).toBe(1);
expect(result.aborted).toBe(false); expect(result.aborted).toBe(false);
expect(result.stderr).toBe(SUBAGENT_WARNING_MISSING_SUBMIT_RESULT); expect(result.stderr).toBe(SUBAGENT_WARNING_MISSING_YIELD);
expect(result.abortReason).toBeUndefined(); expect(result.abortReason).toBeUndefined();
}); });
it("surfaces abort reason when submit_result reports aborted status", async () => { it("surfaces abort reason when yield reports aborted status", async () => {
const session = createMockSession(({ promptIndex, emit, state }) => { const session = createMockSession(({ promptIndex, emit, state }) => {
if (promptIndex === 1) { if (promptIndex === 1) {
const assistant = createAssistantStopMessage("cannot proceed"); const assistant = createAssistantStopMessage("cannot proceed");
@@ -325,7 +325,7 @@ describe("runSubprocess submit_result reminders", () => {
emit({ emit({
type: "tool_execution_end", type: "tool_execution_end",
toolCallId: "tool-abort", toolCallId: "tool-abort",
toolName: "submit_result", toolName: "yield",
result: { result: {
content: [{ type: "text", text: "Task aborted: blocked by permissions" }], content: [{ type: "text", text: "Task aborted: blocked by permissions" }],
details: { status: "aborted", error: "blocked by permissions" }, details: { status: "aborted", error: "blocked by permissions" },
@@ -336,7 +336,7 @@ describe("runSubprocess submit_result reminders", () => {
mockCreateAgentSession(session); mockCreateAgentSession(session);
const result = await runSubprocess({ ...baseOptions, id: "subagent-aborted-submit-result" }); const result = await runSubprocess({ ...baseOptions, id: "subagent-aborted-yield" });
expect(result.aborted).toBe(true); expect(result.aborted).toBe(true);
expect(result.abortReason).toBe("blocked by permissions"); expect(result.abortReason).toBe("blocked by permissions");
}); });
@@ -1,39 +1,39 @@
import { describe, expect, it } from "bun:test"; import { describe, expect, it } from "bun:test";
import { import {
finalizeSubprocessOutput, finalizeSubprocessOutput,
SUBAGENT_WARNING_MISSING_SUBMIT_RESULT, SUBAGENT_WARNING_MISSING_YIELD,
SUBAGENT_WARNING_NULL_SUBMIT_RESULT, SUBAGENT_WARNING_NULL_YIELD,
} from "../../src/task/executor"; } from "../../src/task/executor";
describe("subagent warning injection", () => { describe("subagent warning injection", () => {
it("injects null-data warning when submit_result is success without data", () => { it("injects null-data warning when yield is success without data", () => {
const result = finalizeSubprocessOutput({ const result = finalizeSubprocessOutput({
rawOutput: "partial output", rawOutput: "partial output",
exitCode: 0, exitCode: 0,
stderr: "", stderr: "",
doneAborted: false, doneAborted: false,
signalAborted: false, signalAborted: false,
submitResultItems: [{ status: "success" }], yieldItems: [{ status: "success" }],
outputSchema: undefined, outputSchema: undefined,
}); });
expect(result.rawOutput).toBe(`${SUBAGENT_WARNING_NULL_SUBMIT_RESULT}\n\npartial output`); expect(result.rawOutput).toBe(`${SUBAGENT_WARNING_NULL_YIELD}\n\npartial output`);
expect(result.hasSubmitResult).toBe(true); expect(result.hasYield).toBe(true);
}); });
it("injects missing-submit warning when subagent exits cleanly without submit_result", () => { it("injects missing-submit warning when subagent exits cleanly without yield", () => {
const result = finalizeSubprocessOutput({ const result = finalizeSubprocessOutput({
rawOutput: "", rawOutput: "",
exitCode: 0, exitCode: 0,
stderr: "", stderr: "",
doneAborted: false, doneAborted: false,
signalAborted: false, signalAborted: false,
submitResultItems: undefined, yieldItems: undefined,
outputSchema: { properties: { ok: { type: "boolean" } } }, outputSchema: { properties: { ok: { type: "boolean" } } },
}); });
expect(result.rawOutput).toBe(SUBAGENT_WARNING_MISSING_SUBMIT_RESULT); expect(result.rawOutput).toBe(SUBAGENT_WARNING_MISSING_YIELD);
expect(result.hasSubmitResult).toBe(false); expect(result.hasYield).toBe(false);
}); });
it("does not inject missing-submit warning when fallback completion is recoverable", () => { it("does not inject missing-submit warning when fallback completion is recoverable", () => {
@@ -43,7 +43,7 @@ describe("subagent warning injection", () => {
stderr: "", stderr: "",
doneAborted: false, doneAborted: false,
signalAborted: false, signalAborted: false,
submitResultItems: undefined, yieldItems: undefined,
outputSchema: { type: "object", properties: { ok: { type: "boolean" } }, required: ["ok"] }, outputSchema: { type: "object", properties: { ok: { type: "boolean" } }, required: ["ok"] },
}); });
@@ -58,13 +58,11 @@ describe("subagent warning injection", () => {
stderr: "", stderr: "",
doneAborted: false, doneAborted: false,
signalAborted: false, signalAborted: false,
submitResultItems: undefined, yieldItems: undefined,
outputSchema: { type: "object", properties: { ok: { type: "boolean" } }, required: ["ok"] }, outputSchema: { type: "object", properties: { ok: { type: "boolean" } }, required: ["ok"] },
}); });
expect(result.rawOutput).toBe( expect(result.rawOutput).toBe(`${SUBAGENT_WARNING_MISSING_YIELD}\n\nagent stopped after writing analysis`);
`${SUBAGENT_WARNING_MISSING_SUBMIT_RESULT}\n\nagent stopped after writing analysis`,
);
}); });
it("does not inject missing-submit warning when execution exits non-zero", () => { it("does not inject missing-submit warning when execution exits non-zero", () => {
@@ -74,7 +72,7 @@ describe("subagent warning injection", () => {
stderr: "subagent terminated", stderr: "subagent terminated",
doneAborted: true, doneAborted: true,
signalAborted: false, signalAborted: false,
submitResultItems: undefined, yieldItems: undefined,
outputSchema: { type: "object", properties: { ok: { type: "boolean" } }, required: ["ok"] }, outputSchema: { type: "object", properties: { ok: { type: "boolean" } }, required: ["ok"] },
}); });
@@ -83,32 +81,32 @@ describe("subagent warning injection", () => {
expect(result.exitCode).toBe(1); expect(result.exitCode).toBe(1);
}); });
it("normalizes explicit aborted submit_result into aborted payload", () => { it("normalizes explicit aborted yield into aborted payload", () => {
const result = finalizeSubprocessOutput({ const result = finalizeSubprocessOutput({
rawOutput: "partial output", rawOutput: "partial output",
exitCode: 1, exitCode: 1,
stderr: "old error", stderr: "old error",
doneAborted: false, doneAborted: false,
signalAborted: false, signalAborted: false,
submitResultItems: [{ status: "aborted", error: "blocked by permissions" }], yieldItems: [{ status: "aborted", error: "blocked by permissions" }],
outputSchema: undefined, outputSchema: undefined,
}); });
expect(result.abortedViaSubmitResult).toBe(true); expect(result.abortedViaYield).toBe(true);
expect(result.exitCode).toBe(0); expect(result.exitCode).toBe(0);
expect(result.stderr).toBe("blocked by permissions"); expect(result.stderr).toBe("blocked by permissions");
expect(result.rawOutput).toContain('"aborted": true'); expect(result.rawOutput).toContain('"aborted": true');
expect(result.rawOutput).toContain('"blocked by permissions"'); expect(result.rawOutput).toContain('"blocked by permissions"');
}); });
it("accepts successful submit_result data without warning", () => { it("accepts successful yield data without warning", () => {
const result = finalizeSubprocessOutput({ const result = finalizeSubprocessOutput({
rawOutput: "should be replaced", rawOutput: "should be replaced",
exitCode: 1, exitCode: 1,
stderr: "should clear", stderr: "should clear",
doneAborted: false, doneAborted: false,
signalAborted: false, signalAborted: false,
submitResultItems: [{ status: "success", data: { ok: true } }], yieldItems: [{ status: "success", data: { ok: true } }],
outputSchema: undefined, outputSchema: undefined,
}); });
@@ -125,7 +123,7 @@ describe("subagent warning injection", () => {
stderr: "", stderr: "",
doneAborted: false, doneAborted: false,
signalAborted: false, signalAborted: false,
submitResultItems: undefined, yieldItems: undefined,
outputSchema: undefined, outputSchema: undefined,
}); });
@@ -177,12 +177,12 @@ describe("createTools", () => {
expect(names).toEqual(["report_finding", "exit_plan_mode"]); expect(names).toEqual(["report_finding", "exit_plan_mode"]);
}); });
it("includes submit_result tool when required", async () => { it("includes yield tool when required", async () => {
const session = createTestSession({ requireSubmitResultTool: true }); const session = createTestSession({ requireYieldTool: true });
const tools = await createTools(session); const tools = await createTools(session);
const names = tools.map(t => t.name); const names = tools.map(t => t.name);
expect(names).toContain("submit_result"); expect(names).toContain("yield");
}); });
it("excludes ask tool when hasUI is false", async () => { it("excludes ask tool when hasUI is false", async () => {
@@ -250,7 +250,7 @@ describe("createTools", () => {
"report_finding", "report_finding",
"report_tool_issue", "report_tool_issue",
"resolve", "resolve",
"submit_result", "yield",
]); ]);
}); });
}); });
@@ -1,14 +1,14 @@
import { describe, expect, it } from "bun:test"; import { describe, expect, it } from "bun:test";
import "../../src/tools/submit-result"; import "../../src/tools/yield";
import { subprocessToolRegistry } from "../../src/task/subprocess-tool-registry"; import { subprocessToolRegistry } from "../../src/task/subprocess-tool-registry";
describe("submit_result subprocess extraction", () => { describe("yield subprocess extraction", () => {
const handler = subprocessToolRegistry.getHandler("submit_result"); const handler = subprocessToolRegistry.getHandler("yield");
it("extracts valid submit_result payloads", () => { it("extracts valid yield payloads", () => {
expect(handler?.extractData).toBeDefined(); expect(handler?.extractData).toBeDefined();
const data = handler?.extractData?.({ const data = handler?.extractData?.({
toolName: "submit_result", toolName: "yield",
toolCallId: "call-1", toolCallId: "call-1",
result: { result: {
content: [{ type: "text", text: "Result submitted." }], content: [{ type: "text", text: "Result submitted." }],
@@ -19,9 +19,9 @@ describe("submit_result subprocess extraction", () => {
expect(data).toEqual({ status: "success", data: { ok: true }, error: undefined }); expect(data).toEqual({ status: "success", data: { ok: true }, error: undefined });
}); });
it("ignores malformed submit_result details without status", () => { it("ignores malformed yield details without status", () => {
const data = handler?.extractData?.({ const data = handler?.extractData?.({
toolName: "submit_result", toolName: "yield",
toolCallId: "call-2", toolCallId: "call-2",
result: { result: {
content: [{ type: "text", text: "Tool execution was aborted." }], content: [{ type: "text", text: "Tool execution was aborted." }],
@@ -4,7 +4,7 @@ import { enforceStrictSchema } from "@oh-my-pi/pi-ai/utils/schema";
import { validateToolArguments } from "@oh-my-pi/pi-ai/utils/validation"; import { validateToolArguments } from "@oh-my-pi/pi-ai/utils/validation";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
import { SubmitResultTool } from "@oh-my-pi/pi-coding-agent/tools/submit-result"; import { YieldTool } from "@oh-my-pi/pi-coding-agent/tools/yield";
function createSession(overrides: Partial<ToolSession> = {}): ToolSession { function createSession(overrides: Partial<ToolSession> = {}): ToolSession {
return { return {
@@ -34,9 +34,9 @@ function getSuccessDataSchema(parameters: Record<string, unknown>): Record<strin
throw new Error("Missing success variant with data schema"); throw new Error("Missing success variant with data schema");
} }
describe("SubmitResultTool", () => { describe("YieldTool", () => {
it("exposes top-level object parameters with required result union", () => { it("exposes top-level object parameters with required result union", () => {
const tool = new SubmitResultTool(createSession()); const tool = new YieldTool(createSession());
const schema = tool.parameters as { const schema = tool.parameters as {
type?: string; type?: string;
properties?: Record<string, unknown>; properties?: Record<string, unknown>;
@@ -48,19 +48,19 @@ describe("SubmitResultTool", () => {
}); });
it("accepts success payload with data", async () => { it("accepts success payload with data", async () => {
const tool = new SubmitResultTool(createSession()); const tool = new YieldTool(createSession());
const result = await tool.execute("call-1", { result: { data: { ok: true } } } as never); const result = await tool.execute("call-1", { result: { data: { ok: true } } } as never);
expect(result.details).toEqual({ data: { ok: true }, status: "success", error: undefined }); expect(result.details).toEqual({ data: { ok: true }, status: "success", error: undefined });
}); });
it("accepts aborted payload with error only", async () => { it("accepts aborted payload with error only", async () => {
const tool = new SubmitResultTool(createSession()); const tool = new YieldTool(createSession());
const result = await tool.execute("call-2", { result: { error: "blocked" } } as never); const result = await tool.execute("call-2", { result: { error: "blocked" } } as never);
expect(result.details).toEqual({ data: undefined, status: "aborted", error: "blocked" }); expect(result.details).toEqual({ data: undefined, status: "aborted", error: "blocked" });
}); });
it("accepts arbitrary data when outputSchema is null", async () => { it("accepts arbitrary data when outputSchema is null", async () => {
const tool = new SubmitResultTool(createSession({ outputSchema: null })); const tool = new YieldTool(createSession({ outputSchema: null }));
const result = await tool.execute("call-null", { result: { data: { nested: { x: 1 }, ok: true } } } as never); const result = await tool.execute("call-null", { result: { data: { nested: { x: 1 }, ok: true } } } as never);
expect(result.details).toEqual({ expect(result.details).toEqual({
data: { nested: { x: 1 }, ok: true }, data: { nested: { x: 1 }, ok: true },
@@ -70,7 +70,7 @@ describe("SubmitResultTool", () => {
}); });
it("treats outputSchema true as unconstrained and accepts primitive and array data", async () => { it("treats outputSchema true as unconstrained and accepts primitive and array data", async () => {
const tool = new SubmitResultTool(createSession({ outputSchema: true })); const tool = new YieldTool(createSession({ outputSchema: true }));
const dataSchema = getSuccessDataSchema(tool.parameters as unknown as Record<string, unknown>); const dataSchema = getSuccessDataSchema(tool.parameters as unknown as Record<string, unknown>);
expect(dataSchema.type).toBeUndefined(); expect(dataSchema.type).toBeUndefined();
@@ -85,7 +85,7 @@ describe("SubmitResultTool", () => {
}); });
}); });
it("repairs strict schema generation for required-only object output schemas", () => { it("repairs strict schema generation for required-only object output schemas", () => {
const tool = new SubmitResultTool( const tool = new YieldTool(
createSession({ createSession({
outputSchema: { outputSchema: {
type: "object", type: "object",
@@ -103,7 +103,7 @@ describe("SubmitResultTool", () => {
}); });
it("normalizes object/null type arrays into strict-compatible data variants", () => { it("normalizes object/null type arrays into strict-compatible data variants", () => {
const tool = new SubmitResultTool( const tool = new YieldTool(
createSession({ createSession({
outputSchema: { outputSchema: {
type: ["object", "null"], type: ["object", "null"],
@@ -129,7 +129,7 @@ describe("SubmitResultTool", () => {
}); });
it("converts mixed JTD and JSON Schema output definitions into provider-valid schemas", async () => { it("converts mixed JTD and JSON Schema output definitions into provider-valid schemas", async () => {
const tool = new SubmitResultTool( const tool = new YieldTool(
createSession({ createSession({
outputSchema: { outputSchema: {
type: "object", type: "object",
@@ -189,7 +189,7 @@ describe("SubmitResultTool", () => {
}, },
], ],
}; };
const tool = new SubmitResultTool(createSession({ outputSchema })); const tool = new YieldTool(createSession({ outputSchema }));
const parametersRecord = tool.parameters as unknown as Record<string, unknown>; const parametersRecord = tool.parameters as unknown as Record<string, unknown>;
// $defs should NOT be in parameters — refs are inlined // $defs should NOT be in parameters — refs are inlined
expect(parametersRecord.$defs).toBeUndefined(); expect(parametersRecord.$defs).toBeUndefined();
@@ -232,7 +232,7 @@ describe("SubmitResultTool", () => {
]); ]);
}); });
it("falls back to unconstrained object data when output schema is invalid", async () => { it("falls back to unconstrained object data when output schema is invalid", async () => {
const tool = new SubmitResultTool( const tool = new YieldTool(
createSession({ createSession({
outputSchema: { outputSchema: {
type: "object", type: "object",
@@ -263,7 +263,7 @@ describe("SubmitResultTool", () => {
const circularSchema: Record<string, unknown> = { type: "object" }; const circularSchema: Record<string, unknown> = { type: "object" };
circularSchema.self = circularSchema; circularSchema.self = circularSchema;
const tool = new SubmitResultTool(createSession({ outputSchema: circularSchema })); const tool = new YieldTool(createSession({ outputSchema: circularSchema }));
const dataSchema = getSuccessDataSchema(tool.parameters as unknown as Record<string, unknown>); const dataSchema = getSuccessDataSchema(tool.parameters as unknown as Record<string, unknown>);
expect(tool.strict).toBe(false); expect(tool.strict).toBe(false);
@@ -299,7 +299,7 @@ describe("SubmitResultTool", () => {
return root; return root;
}; };
const tool = new SubmitResultTool(createSession({ outputSchema: buildDeepSchema(20_000) })); const tool = new YieldTool(createSession({ outputSchema: buildDeepSchema(20_000) }));
const dataSchema = getSuccessDataSchema(tool.parameters as unknown as Record<string, unknown>); const dataSchema = getSuccessDataSchema(tool.parameters as unknown as Record<string, unknown>);
expect(tool.strict).toBe(false); expect(tool.strict).toBe(false);
@@ -311,7 +311,7 @@ describe("SubmitResultTool", () => {
it("handles non-object output schemas without blocking successful result submission", async () => { it("handles non-object output schemas without blocking successful result submission", async () => {
for (const outputSchema of [[], 123, false]) { for (const outputSchema of [[], 123, false]) {
const tool = new SubmitResultTool(createSession({ outputSchema })); const tool = new YieldTool(createSession({ outputSchema }));
const result = await tool.execute("call-non-object-schema", { const result = await tool.execute("call-non-object-schema", {
result: { data: { value: outputSchema } }, result: { data: { value: outputSchema } },
} as never); } as never);
@@ -333,7 +333,7 @@ describe("SubmitResultTool", () => {
}, },
required: ["token"], required: ["token"],
}; };
const tool = new SubmitResultTool(createSession({ outputSchema })); const tool = new YieldTool(createSession({ outputSchema }));
const dataSchema = getSuccessDataSchema(tool.parameters as unknown as Record<string, unknown>); const dataSchema = getSuccessDataSchema(tool.parameters as unknown as Record<string, unknown>);
const tokenSchema = toRecord(toRecord(dataSchema.properties).token); const tokenSchema = toRecord(toRecord(dataSchema.properties).token);
@@ -357,7 +357,7 @@ describe("SubmitResultTool", () => {
}, },
required: ["token"], required: ["token"],
}; };
const tool = new SubmitResultTool(createSession({ outputSchema })); const tool = new YieldTool(createSession({ outputSchema }));
await expect(tool.execute("call-short-1", { result: { data: { token: "ab" } } } as never)).rejects.toThrow( await expect(tool.execute("call-short-1", { result: { data: { token: "ab" } } } as never)).rejects.toThrow(
"Output does not match schema", "Output does not match schema",
@@ -384,7 +384,7 @@ describe("SubmitResultTool", () => {
}, },
required: ["token"], required: ["token"],
}; };
const tool = new SubmitResultTool(createSession({ outputSchema })); const tool = new YieldTool(createSession({ outputSchema }));
const firstResult = await tool.execute("call-valid-1", { result: { data: { token: "abcd" } } } as never); const firstResult = await tool.execute("call-valid-1", { result: { data: { token: "abcd" } } } as never);
expect(firstResult.content).toEqual([{ type: "text", text: "Result submitted." }]); expect(firstResult.content).toEqual([{ type: "text", text: "Result submitted." }]);
@@ -408,7 +408,7 @@ describe("SubmitResultTool", () => {
}, },
required: ["token"], required: ["token"],
}; };
const tool = new SubmitResultTool(createSession({ outputSchema })); const tool = new YieldTool(createSession({ outputSchema }));
await expect(tool.execute("call-struct-1", { result: { data: { token: "ab" } } } as never)).rejects.toThrow( await expect(tool.execute("call-struct-1", { result: { data: { token: "ab" } } } as never)).rejects.toThrow(
"Output does not match schema", "Output does not match schema",
@@ -422,13 +422,13 @@ describe("SubmitResultTool", () => {
); );
}); });
it("rejects submissions without a result object", async () => { it("rejects submissions without a result object", async () => {
const tool = new SubmitResultTool(createSession()); const tool = new YieldTool(createSession());
await expect(tool.execute("call-3", {} as never)).rejects.toThrow( await expect(tool.execute("call-3", {} as never)).rejects.toThrow(
"result must be an object containing either data or error", "result must be an object containing either data or error",
); );
}); });
it("sets lenientArgValidation so agent-loop bypasses validation errors", () => { it("sets lenientArgValidation so agent-loop bypasses validation errors", () => {
const tool = new SubmitResultTool(createSession()); const tool = new YieldTool(createSession());
expect(tool.lenientArgValidation).toBe(true); expect(tool.lenientArgValidation).toBe(true);
}); });
}); });