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:
@@ -187,7 +187,7 @@ READ-ONLY if applicable — list prohibited actions explicitly.
|
||||
|
||||
<output>
|
||||
What to return. Schema requirements.
|
||||
Call `submit_result` with findings when done.
|
||||
Call `yield` with findings when done.
|
||||
</output>
|
||||
|
||||
<critical>
|
||||
@@ -577,11 +577,11 @@ READ-ONLY. You are STRICTLY PROHIBITED from:
|
||||
2. Read key sections (not entire files)
|
||||
3. Identify types, interfaces, key functions
|
||||
4. Note dependencies between files
|
||||
5. Call `submit_result` with findings
|
||||
5. Call `yield` with findings
|
||||
</procedure>
|
||||
|
||||
<critical>
|
||||
Read-only. Call `submit_result` when done. This matters.
|
||||
Read-only. Call `yield` when done. This matters.
|
||||
</critical>
|
||||
```
|
||||
|
||||
|
||||
+3
-3
@@ -228,12 +228,12 @@ Related APIs:
|
||||
- Built-ins come from `createTools(...)` and `BUILTIN_TOOLS`.
|
||||
- `toolNames` acts as an allowlist for built-ins.
|
||||
- `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
|
||||
const { session } = await createAgentSession({
|
||||
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):
|
||||
|
||||
- `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
|
||||
- `parentTaskPrefix`: artifact naming prefix for nested task outputs
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ Task agents normalize into `AgentDefinition` (`src/task/types.ts`):
|
||||
Parsing comes from frontmatter via `parseAgentFields()` (`src/discovery/helpers.ts`):
|
||||
|
||||
- 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
|
||||
- backward-compat behavior: if `spawns` missing but `tools` includes `task`, `spawns` becomes `*`
|
||||
- `output` is passed through as opaque schema data
|
||||
|
||||
@@ -544,7 +544,7 @@ describe("agentLoop with AgentMessage", () => {
|
||||
const stream = new MockAssistantStream();
|
||||
queueMicrotask(() => {
|
||||
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",
|
||||
);
|
||||
stream.push({ type: "start", partial });
|
||||
|
||||
@@ -617,7 +617,7 @@ async function createClient(
|
||||
// Azure OpenAI requires /deployments/{id}/chat/completions?api-version=YYYY-MM-DD.
|
||||
// The generic openai-completions path adds neither, producing silent 404s.
|
||||
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";
|
||||
if (!baseUrl.includes("/deployments/")) {
|
||||
baseUrl = `${baseUrl}/deployments/${model.id}`;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### 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
|
||||
|
||||
### Added
|
||||
@@ -13,6 +13,7 @@
|
||||
|
||||
### 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
|
||||
- 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
|
||||
|
||||
@@ -395,7 +395,7 @@ A `ToolFactory` is `(session: ToolSession) => Tool | null | Promise<Tool | null>
|
||||
4. Computes effective gating (`isToolAllowed`) from settings and runtime state:
|
||||
- feature toggles (`find.enabled`, `grep.enabled`, etc.)
|
||||
- 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`.
|
||||
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)
|
||||
@@ -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.
|
||||
- Removes `task` when max recursion depth is reached (`task.maxRecursionDepth`).
|
||||
- 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).
|
||||
|
||||
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
|
||||
|
||||
`executor.ts` enforces structured completion around `submit_result`:
|
||||
`executor.ts` enforces structured completion around `yield`:
|
||||
|
||||
- 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(...)`:
|
||||
- If `submit_result.status === "aborted"`, task is converted to an aborted result payload.
|
||||
- If missing `submit_result`, fallback attempts JSON parse/validation against output schema.
|
||||
- Emits warnings when `submit_result` is missing/null and fallback cannot safely validate.
|
||||
- If `yield.status === "aborted"`, task is converted to an aborted result payload.
|
||||
- If missing `yield`, fallback attempts JSON parse/validation against output schema.
|
||||
- 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`.
|
||||
|
||||
@@ -1132,7 +1132,7 @@ Primary file: `packages/coding-agent/src/tools/index.ts`.
|
||||
- `export const BUILTIN_TOOLS: Record<string, ToolFactory> = { ... }`
|
||||
- Key is the external tool name (e.g. `"read"`, `"web_search"`).
|
||||
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.
|
||||
- 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.
|
||||
@@ -1141,7 +1141,7 @@ Notes from current behavior:
|
||||
|
||||
- `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).
|
||||
- `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.
|
||||
|
||||
### Playbook: add an RPC command
|
||||
|
||||
@@ -19,4 +19,4 @@ Return concise JSON object with:
|
||||
Consider how file's changes relate to above files.
|
||||
{{/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());
|
||||
|
||||
// Subagents with explicit tool lists always need submit_result
|
||||
if (tools && !tools.includes("submit_result")) {
|
||||
tools = [...tools, "submit_result"];
|
||||
// Subagents with explicit tool lists always need yield
|
||||
if (tools && !tools.includes("yield")) {
|
||||
tools = [...tools, "yield"];
|
||||
}
|
||||
|
||||
// Parse spawns field (array, "*", or CSV)
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1178,8 +1178,8 @@
|
||||
return html;
|
||||
}
|
||||
|
||||
function renderSubmitResult(name, args, result, ctx) {
|
||||
let html = toolHead('submit_result');
|
||||
function renderYield(name, args, result, ctx) {
|
||||
let html = toolHead('yield');
|
||||
if (args.data !== undefined) {
|
||||
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_prs: renderGh,
|
||||
render_mermaid: renderMermaid,
|
||||
submit_result: renderSubmitResult,
|
||||
yield: renderYield,
|
||||
report_finding: renderReportFinding,
|
||||
report_tool_issue: renderReportToolIssue,
|
||||
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.
|
||||
|
||||
## 5. Report
|
||||
- Call `submit_result` with structured findings.
|
||||
- Call `yield` with structured findings.
|
||||
- Every `sources` entry **MUST** include a verbatim excerpt.
|
||||
- The `api` array **MUST** contain exact signatures copied from source.
|
||||
- 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
|
||||
2. Read modified files for full context
|
||||
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.
|
||||
</procedure>
|
||||
@@ -111,7 +111,7 @@ Each `report_finding` requires:
|
||||
- `file_path`: Absolute path
|
||||
- `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.explanation`: Plain text, 1-3 sentences summarizing verdict. Don't repeat findings (captured via `report_finding`).
|
||||
- `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}}
|
||||
3. **MAY** read full file context as needed via `read`
|
||||
4. Call `report_finding` per issue
|
||||
5. Call `submit_result` with verdict when done
|
||||
5. Call `yield` with verdict when done
|
||||
|
||||
{{#if skipDiff}}
|
||||
### 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}}
|
||||
|
||||
{{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.
|
||||
|
||||
@@ -29,7 +29,7 @@ Your result **MUST** match this TypeScript interface:
|
||||
{{/if}}
|
||||
|
||||
{{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** 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>
|
||||
@@ -209,8 +209,8 @@ export interface CreateAgentSessionOptions {
|
||||
|
||||
/** Output schema for structured completion (subagents) */
|
||||
outputSchema?: unknown;
|
||||
/** Whether to include the submit_result tool by default */
|
||||
requireSubmitResultTool?: boolean;
|
||||
/** Whether to include the yield tool by default */
|
||||
requireYieldTool?: boolean;
|
||||
/** Task recursion depth (for subagent sessions). Default: 0 */
|
||||
taskDepth?: number;
|
||||
/** Parent task ID prefix for nested artifact naming (e.g., "6-Extensions") */
|
||||
@@ -916,7 +916,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
||||
skills,
|
||||
eventBus,
|
||||
outputSchema: options.outputSchema,
|
||||
requireSubmitResultTool: options.requireSubmitResultTool,
|
||||
requireYieldTool: options.requireYieldTool,
|
||||
taskDepth: options.taskDepth ?? 0,
|
||||
getSessionFile: () => sessionManager.getSessionFile() ?? null,
|
||||
getPythonKernelOwnerId: () => pythonKernelOwnerId,
|
||||
|
||||
@@ -18,8 +18,8 @@ import { runExtensionCompact, runExtensionSetModel } from "../extensibility/exte
|
||||
import type { Skill } from "../extensibility/skills";
|
||||
import { callTool } from "../mcp/client";
|
||||
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 submitReminderTemplate from "../prompts/system/subagent-yield-reminder.md" with { type: "text" };
|
||||
import { createAgentSession, discoverAuthStorage } from "../sdk";
|
||||
import type { AgentSession, AgentSessionEvent } from "../session/agent-session";
|
||||
import type { AuthStorage } from "../session/auth-storage";
|
||||
@@ -223,7 +223,7 @@ function resolveFallbackCompletion(rawOutput: string, outputSchema: unknown): {
|
||||
return { data: candidate };
|
||||
}
|
||||
|
||||
export interface SubmitResultItem {
|
||||
export interface YieldItem {
|
||||
data?: unknown;
|
||||
status?: "success" | "aborted";
|
||||
error?: string;
|
||||
@@ -235,7 +235,7 @@ interface FinalizeSubprocessOutputArgs {
|
||||
stderr: string;
|
||||
doneAborted: boolean;
|
||||
signalAborted: boolean;
|
||||
submitResultItems?: SubmitResultItem[];
|
||||
yieldItems?: YieldItem[];
|
||||
reportFindings?: ReviewFinding[];
|
||||
outputSchema: unknown;
|
||||
}
|
||||
@@ -244,44 +244,42 @@ interface FinalizeSubprocessOutputResult {
|
||||
rawOutput: string;
|
||||
exitCode: number;
|
||||
stderr: string;
|
||||
abortedViaSubmitResult: boolean;
|
||||
hasSubmitResult: boolean;
|
||||
abortedViaYield: boolean;
|
||||
hasYield: boolean;
|
||||
}
|
||||
|
||||
export const SUBAGENT_WARNING_NULL_SUBMIT_RESULT = "SYSTEM WARNING: Subagent called submit_result with null data.";
|
||||
export const SUBAGENT_WARNING_MISSING_SUBMIT_RESULT =
|
||||
"SYSTEM WARNING: Subagent exited without calling submit_result tool after 3 reminders.";
|
||||
export const SUBAGENT_WARNING_NULL_YIELD = "SYSTEM WARNING: Subagent called yield with null data.";
|
||||
export const SUBAGENT_WARNING_MISSING_YIELD =
|
||||
"SYSTEM WARNING: Subagent exited without calling yield tool after 3 reminders.";
|
||||
|
||||
export function finalizeSubprocessOutput(args: FinalizeSubprocessOutputArgs): FinalizeSubprocessOutputResult {
|
||||
let { rawOutput, exitCode, stderr } = args;
|
||||
const { submitResultItems, reportFindings, doneAborted, signalAborted, outputSchema } = args;
|
||||
let abortedViaSubmitResult = false;
|
||||
const hasSubmitResult = Array.isArray(submitResultItems) && submitResultItems.length > 0;
|
||||
const { yieldItems, reportFindings, doneAborted, signalAborted, outputSchema } = args;
|
||||
let abortedViaYield = false;
|
||||
const hasYield = Array.isArray(yieldItems) && yieldItems.length > 0;
|
||||
|
||||
if (hasSubmitResult) {
|
||||
const lastSubmitResult = submitResultItems[submitResultItems.length - 1];
|
||||
if (lastSubmitResult?.status === "aborted") {
|
||||
abortedViaSubmitResult = true;
|
||||
if (hasYield) {
|
||||
const lastYield = yieldItems[yieldItems.length - 1];
|
||||
if (lastYield?.status === "aborted") {
|
||||
abortedViaYield = true;
|
||||
exitCode = 0;
|
||||
stderr = lastSubmitResult.error || "Subagent aborted task";
|
||||
stderr = lastYield.error || "Subagent aborted task";
|
||||
try {
|
||||
rawOutput = JSON.stringify({ aborted: true, error: lastSubmitResult.error }, null, 2);
|
||||
rawOutput = JSON.stringify({ aborted: true, error: lastYield.error }, null, 2);
|
||||
} catch {
|
||||
rawOutput = `{"aborted":true,"error":"${lastSubmitResult.error || "Unknown error"}"}`;
|
||||
rawOutput = `{"aborted":true,"error":"${lastYield.error || "Unknown error"}"}`;
|
||||
}
|
||||
} else {
|
||||
const submitData = lastSubmitResult?.data;
|
||||
const submitData = lastYield?.data;
|
||||
if (submitData === null || submitData === undefined) {
|
||||
rawOutput = rawOutput
|
||||
? `${SUBAGENT_WARNING_NULL_SUBMIT_RESULT}\n\n${rawOutput}`
|
||||
: SUBAGENT_WARNING_NULL_SUBMIT_RESULT;
|
||||
rawOutput = rawOutput ? `${SUBAGENT_WARNING_NULL_YIELD}\n\n${rawOutput}` : SUBAGENT_WARNING_NULL_YIELD;
|
||||
} else {
|
||||
const completeData = normalizeCompleteData(submitData, reportFindings);
|
||||
try {
|
||||
rawOutput = JSON.stringify(completeData, null, 2) ?? "null";
|
||||
} catch (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;
|
||||
stderr = "";
|
||||
@@ -307,17 +305,15 @@ export function finalizeSubprocessOutput(args: FinalizeSubprocessOutputArgs): Fi
|
||||
stderr = "";
|
||||
} else if (exitCode === 0) {
|
||||
const hasRawOutput = rawOutput.trim().length > 0;
|
||||
rawOutput = rawOutput
|
||||
? `${SUBAGENT_WARNING_MISSING_SUBMIT_RESULT}\n\n${rawOutput}`
|
||||
: SUBAGENT_WARNING_MISSING_SUBMIT_RESULT;
|
||||
rawOutput = rawOutput ? `${SUBAGENT_WARNING_MISSING_YIELD}\n\n${rawOutput}` : SUBAGENT_WARNING_MISSING_YIELD;
|
||||
if (hasOutputSchema || !hasRawOutput) {
|
||||
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;
|
||||
let activeSession: AgentSession | 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)
|
||||
const accumulatedUsage = {
|
||||
@@ -789,8 +785,8 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
|
||||
existing.push(data);
|
||||
}
|
||||
progress.extractedToolData[event.toolName] = existing;
|
||||
if (event.toolName === "submit_result") {
|
||||
submitResultCalled = true;
|
||||
if (event.toolName === "yield") {
|
||||
yieldCalled = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -955,7 +951,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
|
||||
thinkingLevel: effectiveThinkingLevel,
|
||||
toolNames,
|
||||
outputSchema,
|
||||
requireSubmitResultTool: true,
|
||||
requireYieldTool: true,
|
||||
contextFiles: options.contextFiles,
|
||||
skills: options.skills,
|
||||
promptTemplates: options.promptTemplates,
|
||||
@@ -1070,7 +1066,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
|
||||
await extensionRunner.emit({ type: "session_start" });
|
||||
}
|
||||
|
||||
const MAX_SUBMIT_RESULT_RETRIES = 3;
|
||||
const MAX_YIELD_RETRIES = 3;
|
||||
unsubscribe = session.subscribe(event => {
|
||||
if (isAgentEvent(event)) {
|
||||
try {
|
||||
@@ -1087,15 +1083,15 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
|
||||
await session.prompt(task, { attribution: "agent" });
|
||||
await session.waitForIdle();
|
||||
|
||||
const reminderToolChoice = buildNamedToolChoice("submit_result", session.model);
|
||||
const reminderToolChoice = buildNamedToolChoice("yield", session.model);
|
||||
|
||||
let retryCount = 0;
|
||||
while (!submitResultCalled && retryCount < MAX_SUBMIT_RESULT_RETRIES && !abortSignal.aborted) {
|
||||
while (!yieldCalled && retryCount < MAX_YIELD_RETRIES && !abortSignal.aborted) {
|
||||
try {
|
||||
retryCount++;
|
||||
const reminder = prompt.render(submitReminderTemplate, {
|
||||
retryCount,
|
||||
maxRetries: MAX_SUBMIT_RESULT_RETRIES,
|
||||
maxRetries: MAX_YIELD_RETRIES,
|
||||
});
|
||||
|
||||
await session.prompt(reminder, {
|
||||
@@ -1111,7 +1107,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
|
||||
}
|
||||
|
||||
await session.waitForIdle();
|
||||
if (!submitResultCalled && !abortSignal.aborted) {
|
||||
if (!yieldCalled && !abortSignal.aborted) {
|
||||
exitCode = 0;
|
||||
}
|
||||
|
||||
@@ -1186,7 +1182,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
|
||||
|
||||
// Use final output if available, otherwise accumulated output
|
||||
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 finalized = finalizeSubprocessOutput({
|
||||
rawOutput,
|
||||
@@ -1194,17 +1190,16 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
|
||||
stderr,
|
||||
doneAborted: Boolean(done.aborted),
|
||||
signalAborted: Boolean(signal?.aborted),
|
||||
submitResultItems,
|
||||
yieldItems,
|
||||
reportFindings,
|
||||
outputSchema,
|
||||
});
|
||||
rawOutput = finalized.rawOutput;
|
||||
exitCode = finalized.exitCode;
|
||||
stderr = finalized.stderr;
|
||||
const lastSubmitResult = submitResultItems?.[submitResultItems.length - 1];
|
||||
const submitResultAbortReason =
|
||||
lastSubmitResult?.status === "aborted" ? lastSubmitResult.error || "Subagent aborted task" : undefined;
|
||||
const { abortedViaSubmitResult, hasSubmitResult } = finalized;
|
||||
const lastYield = yieldItems?.[yieldItems.length - 1];
|
||||
const yieldAbortReason = lastYield?.status === "aborted" ? lastYield.error || "Subagent aborted task" : undefined;
|
||||
const { abortedViaYield, hasYield } = finalized;
|
||||
const { content: truncatedOutput, truncated } = truncateTail(rawOutput, {
|
||||
maxBytes: MAX_OUTPUT_BYTES,
|
||||
maxLines: MAX_OUTPUT_LINES,
|
||||
@@ -1228,16 +1223,16 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
|
||||
}
|
||||
|
||||
// Update final progress
|
||||
const wasAborted = abortedViaSubmitResult || (!hasSubmitResult && (done.aborted || signal?.aborted || false));
|
||||
const wasAborted = abortedViaYield || (!hasYield && (done.aborted || signal?.aborted || false));
|
||||
const finalAbortReason = wasAborted
|
||||
? abortedViaSubmitResult
|
||||
? submitResultAbortReason
|
||||
? abortedViaYield
|
||||
? yieldAbortReason
|
||||
: (done.abortReason ?? (signal?.aborted ? resolveSignalAbortReason() : "Subagent aborted task"))
|
||||
: undefined;
|
||||
progress.status = wasAborted ? "aborted" : exitCode === 0 ? "completed" : "failed";
|
||||
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) {
|
||||
options.eventBus.emit(TASK_SUBAGENT_LIFECYCLE_CHANNEL, {
|
||||
id,
|
||||
|
||||
@@ -101,12 +101,12 @@ function formatTaskId(id: string): string {
|
||||
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 firstLine = lines[0]?.trim() ?? "";
|
||||
if (!firstLine.startsWith(MISSING_SUBMIT_RESULT_WARNING_PREFIX)) {
|
||||
if (!firstLine.startsWith(MISSING_YIELD_WARNING_PREFIX)) {
|
||||
return { rest: output };
|
||||
}
|
||||
const rest = lines
|
||||
@@ -572,9 +572,9 @@ function renderAgentProgress(
|
||||
|
||||
// Render extracted tool data inline (e.g., review findings)
|
||||
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") {
|
||||
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 reviewData = completeData
|
||||
?.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 continuePrefix = isLast ? " " : `${theme.fg("dim", theme.tree.vertical)} `;
|
||||
|
||||
const { warning: missingCompleteWarning, rest: outputWithoutWarning } = extractMissingSubmitResultWarning(
|
||||
result.output,
|
||||
);
|
||||
const { warning: missingCompleteWarning, rest: outputWithoutWarning } = extractMissingYieldWarning(result.output);
|
||||
const aborted = result.aborted ?? false;
|
||||
const mergeFailed = !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))}`,
|
||||
);
|
||||
}
|
||||
// Check for review result (submit_result with review schema + report_finding)
|
||||
const completeData = result.extractedToolData?.submit_result as Array<{ data: unknown }> | undefined;
|
||||
// Check for review result (yield with review schema + report_finding)
|
||||
const completeData = result.extractedToolData?.yield as Array<{ data: unknown }> | undefined;
|
||||
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
|
||||
?.map(c => c.data as SubmitReviewDetails)
|
||||
.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 message = hasCompleteData
|
||||
? "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}${formatFindingSummary(reportFindingData, theme)}`);
|
||||
lines.push(...renderFindings(reportFindingData, continuePrefix, expanded, theme));
|
||||
@@ -817,7 +815,7 @@ function renderAgentResult(result: SingleResult, isLast: boolean, expanded: bool
|
||||
if (result.extractedToolData) {
|
||||
for (const [toolName, dataArray] of Object.entries(result.extractedToolData)) {
|
||||
// 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);
|
||||
if (handler?.renderFinal && (dataArray as unknown[]).length > 0) {
|
||||
|
||||
@@ -53,9 +53,9 @@ import { ResolveTool } from "./resolve";
|
||||
import { reportFindingTool } from "./review";
|
||||
import { SearchToolBm25Tool } from "./search-tool-bm25";
|
||||
import { loadSshTool } from "./ssh";
|
||||
import { SubmitResultTool } from "./submit-result";
|
||||
import { type TodoPhase, TodoWriteTool } from "./todo-write";
|
||||
import { WriteTool } from "./write";
|
||||
import { YieldTool } from "./yield";
|
||||
|
||||
// Exa MCP tools (22 tools)
|
||||
|
||||
@@ -91,10 +91,10 @@ export * from "./resolve";
|
||||
export * from "./review";
|
||||
export * from "./search-tool-bm25";
|
||||
export * from "./ssh";
|
||||
export * from "./submit-result";
|
||||
export * from "./todo-write";
|
||||
export * from "./vim";
|
||||
export * from "./write";
|
||||
export * from "./yield";
|
||||
|
||||
/** Tool type (AgentTool from pi-ai) */
|
||||
export type Tool = AgentTool<any, any, any>;
|
||||
@@ -131,8 +131,8 @@ export interface ToolSession {
|
||||
eventBus?: EventBus;
|
||||
/** Output schema for structured completion (subagents) */
|
||||
outputSchema?: unknown;
|
||||
/** Whether to include the submit_result tool by default */
|
||||
requireSubmitResultTool?: boolean;
|
||||
/** Whether to include the yield tool by default */
|
||||
requireYieldTool?: boolean;
|
||||
/** Task recursion depth (0 = top-level, 1 = first child, etc.) */
|
||||
taskDepth?: number;
|
||||
/** Get session file */
|
||||
@@ -245,7 +245,7 @@ export const BUILTIN_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_tool_issue: s => createReportToolIssueTool(s),
|
||||
exit_plan_mode: s => new ExitPlanModeTool(s),
|
||||
@@ -288,7 +288,7 @@ function getPythonModeFromEnv(): PythonToolMode | null {
|
||||
* Create tools from BUILTIN_TOOLS registry.
|
||||
*/
|
||||
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 requestedTools =
|
||||
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 === "python") return allowPython;
|
||||
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 === "grep") return session.settings.get("grep.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;
|
||||
};
|
||||
if (includeSubmitResult && requestedTools && !requestedTools.includes("submit_result")) {
|
||||
requestedTools.push("submit_result");
|
||||
if (includeYield && requestedTools && !requestedTools.includes("yield")) {
|
||||
requestedTools.push("yield");
|
||||
}
|
||||
|
||||
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)
|
||||
: [
|
||||
...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),
|
||||
];
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*
|
||||
* Used by the reviewer agent to report findings in a structured way.
|
||||
* 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
|
||||
@@ -131,7 +131,7 @@ export function parseReportFindingDetails(value: unknown): ReportFindingDetails
|
||||
export const reportFindingTool: AgentTool<typeof ReportFindingParams, ReportFindingDetails, Theme> = {
|
||||
name: "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,
|
||||
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
||||
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 {
|
||||
overall_correctness: "correct" | "incorrect";
|
||||
explanation: string;
|
||||
|
||||
+7
-7
@@ -12,7 +12,7 @@ import { subprocessToolRegistry } from "../task/subprocess-tool-registry";
|
||||
import type { ToolSession } from ".";
|
||||
import { jtdToJsonSchema, normalizeSchema } from "./jtd-to-json-schema";
|
||||
|
||||
export interface SubmitResultDetails {
|
||||
export interface YieldDetails {
|
||||
data: unknown;
|
||||
status: "success" | "aborted";
|
||||
error?: string;
|
||||
@@ -40,8 +40,8 @@ function formatAjvErrors(errors: ErrorObject[] | null | undefined): string {
|
||||
.join("; ");
|
||||
}
|
||||
|
||||
export class SubmitResultTool implements AgentTool<TSchema, SubmitResultDetails> {
|
||||
readonly name = "submit_result";
|
||||
export class YieldTool implements AgentTool<TSchema, YieldDetails> {
|
||||
readonly name = "yield";
|
||||
readonly label = "Submit Result";
|
||||
readonly description =
|
||||
"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,
|
||||
params: Static<TSchema>,
|
||||
_signal?: AbortSignal,
|
||||
_onUpdate?: AgentToolUpdateCallback<SubmitResultDetails>,
|
||||
_onUpdate?: AgentToolUpdateCallback<YieldDetails>,
|
||||
_context?: AgentToolContext,
|
||||
): Promise<AgentToolResult<SubmitResultDetails>> {
|
||||
): Promise<AgentToolResult<YieldDetails>> {
|
||||
const raw = params as Record<string, unknown>;
|
||||
const rawResult = raw.result;
|
||||
if (!rawResult || typeof rawResult !== "object" || Array.isArray(rawResult)) {
|
||||
@@ -169,7 +169,7 @@ export class SubmitResultTool implements AgentTool<TSchema, SubmitResultDetails>
|
||||
let schemaValidationOverridden = false;
|
||||
if (status === "success") {
|
||||
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)) {
|
||||
this.#schemaValidationFailures++;
|
||||
@@ -194,7 +194,7 @@ export class SubmitResultTool implements AgentTool<TSchema, SubmitResultDetails>
|
||||
}
|
||||
|
||||
// Register subprocess tool handler for extraction + termination.
|
||||
subprocessToolRegistry.register<SubmitResultDetails>("submit_result", {
|
||||
subprocessToolRegistry.register<YieldDetails>("yield", {
|
||||
extractData: event => {
|
||||
const details = event.result?.details;
|
||||
if (!details || typeof details !== "object") return undefined;
|
||||
@@ -64,6 +64,6 @@ describe("parseAgentFields", () => {
|
||||
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");
|
||||
});
|
||||
|
||||
|
||||
test("headers-only runtime override preserves existing baseUrl across refresh", async () => {
|
||||
const registry = new ModelRegistry(authStorage, modelsJsonPath);
|
||||
const modelId = "runtime-headers-only-baseurl-survivor";
|
||||
@@ -292,11 +291,7 @@ describe("ModelRegistry runtime provider registration", () => {
|
||||
const registry = new ModelRegistry(authStorage, modelsJsonPath);
|
||||
expect(registry.find("anthropic", modelId)?.headers?.[sharedHeader]).toBe(configHeaderValue);
|
||||
|
||||
registry.registerProvider(
|
||||
"anthropic",
|
||||
{ headers: { [sharedHeader]: runtimeHeaderValue } },
|
||||
"ext://runtime",
|
||||
);
|
||||
registry.registerProvider("anthropic", { headers: { [sharedHeader]: runtimeHeaderValue } }, "ext://runtime");
|
||||
await expectProviderHeaderAcrossRefresh(registry, "anthropic", sharedHeader, runtimeHeaderValue);
|
||||
|
||||
registry.clearSourceRegistrations("ext://runtime");
|
||||
@@ -433,18 +428,10 @@ describe("ModelRegistry runtime provider registration", () => {
|
||||
const sourceAHeader = "X-Source-A-Header";
|
||||
const sourceBHeader = "X-Source-B-Header";
|
||||
|
||||
registry.registerProvider(
|
||||
providerName,
|
||||
{ headers: { [sourceAHeader]: "from-source-a" } },
|
||||
"ext://a",
|
||||
);
|
||||
registry.registerProvider(providerName, { headers: { [sourceAHeader]: "from-source-a" } }, "ext://a");
|
||||
expectProviderHeader(registry, providerName, sourceAHeader, "from-source-a");
|
||||
|
||||
registry.registerProvider(
|
||||
providerName,
|
||||
{ headers: { [sourceBHeader]: "from-source-b" } },
|
||||
"ext://b",
|
||||
);
|
||||
registry.registerProvider(providerName, { headers: { [sourceBHeader]: "from-source-b" } }, "ext://b");
|
||||
await expectProviderHeaderAcrossRefresh(registry, providerName, sourceAHeader, undefined);
|
||||
expectProviderHeader(registry, providerName, sourceBHeader, "from-source-b");
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { CreateAgentSessionResult } from "../../src/sdk";
|
||||
import * as sdkModule from "../../src/sdk";
|
||||
import type { AgentSession, AgentSessionEvent, PromptOptions } from "../../src/session/agent-session";
|
||||
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 { EventBus } from "../../src/utils/event-bus";
|
||||
|
||||
@@ -55,7 +55,7 @@ function createMockSession(
|
||||
sessionManager: {
|
||||
appendSessionInit: () => {},
|
||||
},
|
||||
getActiveToolNames: () => ["read", "submit_result"],
|
||||
getActiveToolNames: () => ["read", "yield"],
|
||||
setActiveToolsByName: async (_toolNames: string[]) => {},
|
||||
subscribe: (listener: (event: AgentSessionEvent) => void) => {
|
||||
listeners.push(listener);
|
||||
@@ -90,7 +90,7 @@ function mockCreateAgentSession(session: AgentSession) {
|
||||
return vi.spyOn(sdkModule, "createAgentSession").mockResolvedValue(createSessionResult(session));
|
||||
}
|
||||
|
||||
describe("runSubprocess submit_result reminders", () => {
|
||||
describe("runSubprocess yield reminders", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
@@ -114,7 +114,7 @@ describe("runSubprocess submit_result reminders", () => {
|
||||
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 promptOptions: Array<PromptOptions | undefined> = [];
|
||||
const session = createMockSession(({ text, options, promptIndex, emit, state }) => {
|
||||
@@ -129,7 +129,7 @@ describe("runSubprocess submit_result reminders", () => {
|
||||
emit({
|
||||
type: "tool_execution_end",
|
||||
toolCallId: "tool-1",
|
||||
toolName: "submit_result",
|
||||
toolName: "yield",
|
||||
result: {
|
||||
content: [{ type: "text", text: "Result submitted." }],
|
||||
details: { status: "success", data: { done: true } },
|
||||
@@ -145,12 +145,12 @@ describe("runSubprocess submit_result reminders", () => {
|
||||
expect(promptOptions).toHaveLength(2);
|
||||
expect(promptOptions[0]?.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.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 }) => {
|
||||
if (promptIndex === 1) {
|
||||
const assistant = createAssistantStopMessage("partial output");
|
||||
@@ -161,7 +161,7 @@ describe("runSubprocess submit_result reminders", () => {
|
||||
emit({
|
||||
type: "tool_execution_end",
|
||||
toolCallId: "tool-2",
|
||||
toolName: "submit_result",
|
||||
toolName: "yield",
|
||||
result: {
|
||||
content: [{ type: "text", text: "Result submitted." }],
|
||||
details: { status: "success" },
|
||||
@@ -173,21 +173,21 @@ describe("runSubprocess submit_result reminders", () => {
|
||||
mockCreateAgentSession(session);
|
||||
|
||||
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 session = createMockSession(({ text, promptIndex, emit, state }) => {
|
||||
prompts.push(text);
|
||||
if (promptIndex === 1) {
|
||||
const assistant = createAssistantStopMessage("attempted submit_result");
|
||||
const assistant = createAssistantStopMessage("attempted yield");
|
||||
state.messages.push(assistant);
|
||||
emit({ type: "message_end", message: assistant });
|
||||
emit({
|
||||
type: "tool_execution_end",
|
||||
toolCallId: "tool-error",
|
||||
toolName: "submit_result",
|
||||
toolName: "yield",
|
||||
result: {
|
||||
content: [{ type: "text", text: "Output does not match schema" }],
|
||||
details: { status: "error", error: "Output does not match schema" },
|
||||
@@ -199,7 +199,7 @@ describe("runSubprocess submit_result reminders", () => {
|
||||
emit({
|
||||
type: "tool_execution_end",
|
||||
toolCallId: "tool-success",
|
||||
toolName: "submit_result",
|
||||
toolName: "yield",
|
||||
result: {
|
||||
content: [{ type: "text", text: "Result submitted." }],
|
||||
details: { status: "success", data: { ok: true } },
|
||||
@@ -221,7 +221,7 @@ describe("runSubprocess submit_result reminders", () => {
|
||||
emit({
|
||||
type: "tool_execution_end",
|
||||
toolCallId: "tool-thinking-fallback",
|
||||
toolName: "submit_result",
|
||||
toolName: "yield",
|
||||
result: {
|
||||
content: [{ type: "text", text: "Result submitted." }],
|
||||
details: { status: "success", data: { ok: true } },
|
||||
@@ -268,7 +268,7 @@ describe("runSubprocess submit_result reminders", () => {
|
||||
emit({
|
||||
type: "tool_execution_end",
|
||||
toolCallId: `tool-thinking-override-${index}`,
|
||||
toolName: "submit_result",
|
||||
toolName: "yield",
|
||||
result: {
|
||||
content: [{ type: "text", text: "Result submitted." }],
|
||||
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[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 session = createMockSession(({ text, promptIndex, emit, state }) => {
|
||||
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);
|
||||
emit({ type: "message_end", message: assistant });
|
||||
});
|
||||
@@ -311,11 +311,11 @@ describe("runSubprocess submit_result reminders", () => {
|
||||
expect(prompts).toHaveLength(4);
|
||||
expect(result.exitCode).toBe(1);
|
||||
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();
|
||||
});
|
||||
|
||||
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 }) => {
|
||||
if (promptIndex === 1) {
|
||||
const assistant = createAssistantStopMessage("cannot proceed");
|
||||
@@ -325,7 +325,7 @@ describe("runSubprocess submit_result reminders", () => {
|
||||
emit({
|
||||
type: "tool_execution_end",
|
||||
toolCallId: "tool-abort",
|
||||
toolName: "submit_result",
|
||||
toolName: "yield",
|
||||
result: {
|
||||
content: [{ type: "text", text: "Task aborted: blocked by permissions" }],
|
||||
details: { status: "aborted", error: "blocked by permissions" },
|
||||
@@ -336,7 +336,7 @@ describe("runSubprocess submit_result reminders", () => {
|
||||
|
||||
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.abortReason).toBe("blocked by permissions");
|
||||
});
|
||||
|
||||
@@ -1,39 +1,39 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import {
|
||||
finalizeSubprocessOutput,
|
||||
SUBAGENT_WARNING_MISSING_SUBMIT_RESULT,
|
||||
SUBAGENT_WARNING_NULL_SUBMIT_RESULT,
|
||||
SUBAGENT_WARNING_MISSING_YIELD,
|
||||
SUBAGENT_WARNING_NULL_YIELD,
|
||||
} from "../../src/task/executor";
|
||||
|
||||
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({
|
||||
rawOutput: "partial output",
|
||||
exitCode: 0,
|
||||
stderr: "",
|
||||
doneAborted: false,
|
||||
signalAborted: false,
|
||||
submitResultItems: [{ status: "success" }],
|
||||
yieldItems: [{ status: "success" }],
|
||||
outputSchema: undefined,
|
||||
});
|
||||
|
||||
expect(result.rawOutput).toBe(`${SUBAGENT_WARNING_NULL_SUBMIT_RESULT}\n\npartial output`);
|
||||
expect(result.hasSubmitResult).toBe(true);
|
||||
expect(result.rawOutput).toBe(`${SUBAGENT_WARNING_NULL_YIELD}\n\npartial output`);
|
||||
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({
|
||||
rawOutput: "",
|
||||
exitCode: 0,
|
||||
stderr: "",
|
||||
doneAborted: false,
|
||||
signalAborted: false,
|
||||
submitResultItems: undefined,
|
||||
yieldItems: undefined,
|
||||
outputSchema: { properties: { ok: { type: "boolean" } } },
|
||||
});
|
||||
|
||||
expect(result.rawOutput).toBe(SUBAGENT_WARNING_MISSING_SUBMIT_RESULT);
|
||||
expect(result.hasSubmitResult).toBe(false);
|
||||
expect(result.rawOutput).toBe(SUBAGENT_WARNING_MISSING_YIELD);
|
||||
expect(result.hasYield).toBe(false);
|
||||
});
|
||||
|
||||
it("does not inject missing-submit warning when fallback completion is recoverable", () => {
|
||||
@@ -43,7 +43,7 @@ describe("subagent warning injection", () => {
|
||||
stderr: "",
|
||||
doneAborted: false,
|
||||
signalAborted: false,
|
||||
submitResultItems: undefined,
|
||||
yieldItems: undefined,
|
||||
outputSchema: { type: "object", properties: { ok: { type: "boolean" } }, required: ["ok"] },
|
||||
});
|
||||
|
||||
@@ -58,13 +58,11 @@ describe("subagent warning injection", () => {
|
||||
stderr: "",
|
||||
doneAborted: false,
|
||||
signalAborted: false,
|
||||
submitResultItems: undefined,
|
||||
yieldItems: undefined,
|
||||
outputSchema: { type: "object", properties: { ok: { type: "boolean" } }, required: ["ok"] },
|
||||
});
|
||||
|
||||
expect(result.rawOutput).toBe(
|
||||
`${SUBAGENT_WARNING_MISSING_SUBMIT_RESULT}\n\nagent stopped after writing analysis`,
|
||||
);
|
||||
expect(result.rawOutput).toBe(`${SUBAGENT_WARNING_MISSING_YIELD}\n\nagent stopped after writing analysis`);
|
||||
});
|
||||
|
||||
it("does not inject missing-submit warning when execution exits non-zero", () => {
|
||||
@@ -74,7 +72,7 @@ describe("subagent warning injection", () => {
|
||||
stderr: "subagent terminated",
|
||||
doneAborted: true,
|
||||
signalAborted: false,
|
||||
submitResultItems: undefined,
|
||||
yieldItems: undefined,
|
||||
outputSchema: { type: "object", properties: { ok: { type: "boolean" } }, required: ["ok"] },
|
||||
});
|
||||
|
||||
@@ -83,32 +81,32 @@ describe("subagent warning injection", () => {
|
||||
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({
|
||||
rawOutput: "partial output",
|
||||
exitCode: 1,
|
||||
stderr: "old error",
|
||||
doneAborted: false,
|
||||
signalAborted: false,
|
||||
submitResultItems: [{ status: "aborted", error: "blocked by permissions" }],
|
||||
yieldItems: [{ status: "aborted", error: "blocked by permissions" }],
|
||||
outputSchema: undefined,
|
||||
});
|
||||
|
||||
expect(result.abortedViaSubmitResult).toBe(true);
|
||||
expect(result.abortedViaYield).toBe(true);
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stderr).toBe("blocked by permissions");
|
||||
expect(result.rawOutput).toContain('"aborted": true');
|
||||
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({
|
||||
rawOutput: "should be replaced",
|
||||
exitCode: 1,
|
||||
stderr: "should clear",
|
||||
doneAborted: false,
|
||||
signalAborted: false,
|
||||
submitResultItems: [{ status: "success", data: { ok: true } }],
|
||||
yieldItems: [{ status: "success", data: { ok: true } }],
|
||||
outputSchema: undefined,
|
||||
});
|
||||
|
||||
@@ -125,7 +123,7 @@ describe("subagent warning injection", () => {
|
||||
stderr: "",
|
||||
doneAborted: false,
|
||||
signalAborted: false,
|
||||
submitResultItems: undefined,
|
||||
yieldItems: undefined,
|
||||
outputSchema: undefined,
|
||||
});
|
||||
|
||||
|
||||
@@ -177,12 +177,12 @@ describe("createTools", () => {
|
||||
expect(names).toEqual(["report_finding", "exit_plan_mode"]);
|
||||
});
|
||||
|
||||
it("includes submit_result tool when required", async () => {
|
||||
const session = createTestSession({ requireSubmitResultTool: true });
|
||||
it("includes yield tool when required", async () => {
|
||||
const session = createTestSession({ requireYieldTool: true });
|
||||
const tools = await createTools(session);
|
||||
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 () => {
|
||||
@@ -250,7 +250,7 @@ describe("createTools", () => {
|
||||
"report_finding",
|
||||
"report_tool_issue",
|
||||
"resolve",
|
||||
"submit_result",
|
||||
"yield",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
+7
-7
@@ -1,14 +1,14 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import "../../src/tools/submit-result";
|
||||
import "../../src/tools/yield";
|
||||
import { subprocessToolRegistry } from "../../src/task/subprocess-tool-registry";
|
||||
|
||||
describe("submit_result subprocess extraction", () => {
|
||||
const handler = subprocessToolRegistry.getHandler("submit_result");
|
||||
describe("yield subprocess extraction", () => {
|
||||
const handler = subprocessToolRegistry.getHandler("yield");
|
||||
|
||||
it("extracts valid submit_result payloads", () => {
|
||||
it("extracts valid yield payloads", () => {
|
||||
expect(handler?.extractData).toBeDefined();
|
||||
const data = handler?.extractData?.({
|
||||
toolName: "submit_result",
|
||||
toolName: "yield",
|
||||
toolCallId: "call-1",
|
||||
result: {
|
||||
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 });
|
||||
});
|
||||
|
||||
it("ignores malformed submit_result details without status", () => {
|
||||
it("ignores malformed yield details without status", () => {
|
||||
const data = handler?.extractData?.({
|
||||
toolName: "submit_result",
|
||||
toolName: "yield",
|
||||
toolCallId: "call-2",
|
||||
result: {
|
||||
content: [{ type: "text", text: "Tool execution was aborted." }],
|
||||
+21
-21
@@ -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 { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
||||
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 {
|
||||
return {
|
||||
@@ -34,9 +34,9 @@ function getSuccessDataSchema(parameters: Record<string, unknown>): Record<strin
|
||||
throw new Error("Missing success variant with data schema");
|
||||
}
|
||||
|
||||
describe("SubmitResultTool", () => {
|
||||
describe("YieldTool", () => {
|
||||
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 {
|
||||
type?: string;
|
||||
properties?: Record<string, unknown>;
|
||||
@@ -48,19 +48,19 @@ describe("SubmitResultTool", () => {
|
||||
});
|
||||
|
||||
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);
|
||||
expect(result.details).toEqual({ data: { ok: true }, status: "success", error: undefined });
|
||||
});
|
||||
|
||||
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);
|
||||
expect(result.details).toEqual({ data: undefined, status: "aborted", error: "blocked" });
|
||||
});
|
||||
|
||||
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);
|
||||
expect(result.details).toEqual({
|
||||
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 () => {
|
||||
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>);
|
||||
|
||||
expect(dataSchema.type).toBeUndefined();
|
||||
@@ -85,7 +85,7 @@ describe("SubmitResultTool", () => {
|
||||
});
|
||||
});
|
||||
it("repairs strict schema generation for required-only object output schemas", () => {
|
||||
const tool = new SubmitResultTool(
|
||||
const tool = new YieldTool(
|
||||
createSession({
|
||||
outputSchema: {
|
||||
type: "object",
|
||||
@@ -103,7 +103,7 @@ describe("SubmitResultTool", () => {
|
||||
});
|
||||
|
||||
it("normalizes object/null type arrays into strict-compatible data variants", () => {
|
||||
const tool = new SubmitResultTool(
|
||||
const tool = new YieldTool(
|
||||
createSession({
|
||||
outputSchema: {
|
||||
type: ["object", "null"],
|
||||
@@ -129,7 +129,7 @@ describe("SubmitResultTool", () => {
|
||||
});
|
||||
|
||||
it("converts mixed JTD and JSON Schema output definitions into provider-valid schemas", async () => {
|
||||
const tool = new SubmitResultTool(
|
||||
const tool = new YieldTool(
|
||||
createSession({
|
||||
outputSchema: {
|
||||
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>;
|
||||
// $defs should NOT be in parameters — refs are inlined
|
||||
expect(parametersRecord.$defs).toBeUndefined();
|
||||
@@ -232,7 +232,7 @@ describe("SubmitResultTool", () => {
|
||||
]);
|
||||
});
|
||||
it("falls back to unconstrained object data when output schema is invalid", async () => {
|
||||
const tool = new SubmitResultTool(
|
||||
const tool = new YieldTool(
|
||||
createSession({
|
||||
outputSchema: {
|
||||
type: "object",
|
||||
@@ -263,7 +263,7 @@ describe("SubmitResultTool", () => {
|
||||
const circularSchema: Record<string, unknown> = { type: "object" };
|
||||
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>);
|
||||
|
||||
expect(tool.strict).toBe(false);
|
||||
@@ -299,7 +299,7 @@ describe("SubmitResultTool", () => {
|
||||
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>);
|
||||
|
||||
expect(tool.strict).toBe(false);
|
||||
@@ -311,7 +311,7 @@ describe("SubmitResultTool", () => {
|
||||
|
||||
it("handles non-object output schemas without blocking successful result submission", async () => {
|
||||
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", {
|
||||
result: { data: { value: outputSchema } },
|
||||
} as never);
|
||||
@@ -333,7 +333,7 @@ describe("SubmitResultTool", () => {
|
||||
},
|
||||
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 tokenSchema = toRecord(toRecord(dataSchema.properties).token);
|
||||
|
||||
@@ -357,7 +357,7 @@ describe("SubmitResultTool", () => {
|
||||
},
|
||||
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(
|
||||
"Output does not match schema",
|
||||
@@ -384,7 +384,7 @@ describe("SubmitResultTool", () => {
|
||||
},
|
||||
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);
|
||||
expect(firstResult.content).toEqual([{ type: "text", text: "Result submitted." }]);
|
||||
@@ -408,7 +408,7 @@ describe("SubmitResultTool", () => {
|
||||
},
|
||||
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(
|
||||
"Output does not match schema",
|
||||
@@ -422,13 +422,13 @@ describe("SubmitResultTool", () => {
|
||||
);
|
||||
});
|
||||
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(
|
||||
"result must be an object containing either data or error",
|
||||
);
|
||||
});
|
||||
it("sets lenientArgValidation so agent-loop bypasses validation errors", () => {
|
||||
const tool = new SubmitResultTool(createSession());
|
||||
const tool = new YieldTool(createSession());
|
||||
expect(tool.lenientArgValidation).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user