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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user