fix(agent): aborted repeated empty yield loops
- Added a retry cap for untyped empty yield results so weak models terminate as aborted instead of looping forever. - Covered the abort path and retry-budget reset with yield tool tests. Fixes #5095
This commit is contained in:
@@ -2,6 +2,10 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed MiniMax-M3 task subagents retrying empty `yield` results forever; repeated untyped `result: {}` submissions now abort the child with guidance instead of leaving the parent blocked. ([#5095](https://github.com/can1357/oh-my-pi/issues/5095))
|
||||
|
||||
## [16.4.0] - 2026-07-10
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
@@ -198,6 +198,14 @@ function wrapYieldParameters(dataSchema: Record<string, unknown>): Record<string
|
||||
*/
|
||||
const MAX_SCHEMA_RETRIES = 3;
|
||||
|
||||
/**
|
||||
* Max consecutive untyped empty-result submissions before the yield tool fails
|
||||
* the child explicitly. Some weak tool callers can acknowledge the required
|
||||
* wrapper in prose while repeatedly sending `{ result: {} }`; without a hard
|
||||
* stop the parent waits forever.
|
||||
*/
|
||||
const MAX_EMPTY_RESULT_RETRIES = 3;
|
||||
|
||||
export class YieldTool implements AgentTool<TSchema, YieldDetails> {
|
||||
readonly name = "yield";
|
||||
readonly approval = "read" as const;
|
||||
@@ -217,6 +225,7 @@ export class YieldTool implements AgentTool<TSchema, YieldDetails> {
|
||||
#knownSectionLabels: readonly string[] = [];
|
||||
#isKnownSection?: (label: string) => boolean;
|
||||
#schemaValidationFailures = 0;
|
||||
#emptyResultFailures = 0;
|
||||
|
||||
constructor(session: ToolSession) {
|
||||
let validate: ((value: unknown) => JsonSchemaValidationResult) | undefined;
|
||||
@@ -322,8 +331,26 @@ export class YieldTool implements AgentTool<TSchema, YieldDetails> {
|
||||
throw new Error("result cannot contain both data and error");
|
||||
}
|
||||
if (errorMessage === undefined && data === undefined && yieldType === undefined) {
|
||||
this.#emptyResultFailures++;
|
||||
if (this.#emptyResultFailures > MAX_EMPTY_RESULT_RETRIES) {
|
||||
const attemptCount = this.#emptyResultFailures;
|
||||
this.#emptyResultFailures = 0;
|
||||
const error =
|
||||
`yield result stayed empty after ${attemptCount} consecutive attempt(s); aborting child instead of retrying forever. ` +
|
||||
'Submit success as `{ "result": { "data": <your output> } }` or failure as `{ "result": { "error": "message" } }`.';
|
||||
return {
|
||||
content: [{ type: "text", text: `Task aborted: ${error}` }],
|
||||
details: {
|
||||
data: undefined,
|
||||
status: "aborted",
|
||||
error,
|
||||
type: yieldType,
|
||||
},
|
||||
};
|
||||
}
|
||||
const remaining = MAX_EMPTY_RESULT_RETRIES - this.#emptyResultFailures;
|
||||
throw new Error(
|
||||
'result must contain either `data` or `error`. Use `{result: {data: <your output>}}` for success or `{result: {error: "message"}}` for failure.',
|
||||
`result must contain either \`data\` or \`error\`. Use \`{result: {data: <your output>}}\` for success or \`{result: {error: "message"}}\` for failure. Empty untyped result retries remaining before abort: ${remaining}.`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -370,6 +397,7 @@ export class YieldTool implements AgentTool<TSchema, YieldDetails> {
|
||||
}
|
||||
}
|
||||
|
||||
this.#emptyResultFailures = 0;
|
||||
const responseText =
|
||||
status === "aborted"
|
||||
? `Task aborted: ${errorMessage}`
|
||||
|
||||
@@ -526,6 +526,57 @@ describe("YieldTool", () => {
|
||||
).rejects.toThrow("data is required when yield indicates success");
|
||||
});
|
||||
|
||||
it("aborts instead of throwing forever after repeated untyped empty results", async () => {
|
||||
const tool = new YieldTool(createSession());
|
||||
const expectedGuidance =
|
||||
'result must contain either `data` or `error`. Use `{result: {data: <your output>}}` for success or `{result: {error: "message"}}` for failure.';
|
||||
|
||||
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||
await expect(tool.execute(`call-empty-retry-${attempt}`, { result: {} } as never)).rejects.toThrow(
|
||||
expectedGuidance,
|
||||
);
|
||||
}
|
||||
|
||||
const abortResult = await tool.execute("call-empty-abort", { result: {} } as never);
|
||||
const details = abortResult.details;
|
||||
expect(details).toBeDefined();
|
||||
if (!details) throw new Error("missing abort details");
|
||||
expect(details.status).toBe("aborted");
|
||||
expect(details.data).toBeUndefined();
|
||||
expect(String(details.error)).toContain("retrying forever");
|
||||
expect(abortResult.content).toEqual([{ type: "text", text: expect.stringContaining("Task aborted") }]);
|
||||
});
|
||||
|
||||
it("resets the untyped empty-result retry budget after a valid yield", async () => {
|
||||
const tool = new YieldTool(createSession());
|
||||
const expectedGuidance =
|
||||
'result must contain either `data` or `error`. Use `{result: {data: <your output>}}` for success or `{result: {error: "message"}}` for failure.';
|
||||
|
||||
for (let attempt = 1; attempt <= 2; attempt++) {
|
||||
await expect(tool.execute(`call-empty-before-valid-${attempt}`, { result: {} } as never)).rejects.toThrow(
|
||||
expectedGuidance,
|
||||
);
|
||||
}
|
||||
|
||||
const validResult = await tool.execute("call-valid-reset", { result: { data: { ok: true } } } as never);
|
||||
expect(validResult.details).toEqual({ data: { ok: true }, status: "success", error: undefined });
|
||||
|
||||
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||
await expect(tool.execute(`call-empty-after-valid-${attempt}`, { result: {} } as never)).rejects.toThrow(
|
||||
expectedGuidance,
|
||||
);
|
||||
}
|
||||
|
||||
const abortResult = await tool.execute("call-empty-after-reset-abort", { result: {} } as never);
|
||||
const details = abortResult.details;
|
||||
expect(details).toBeDefined();
|
||||
if (!details) throw new Error("missing abort details");
|
||||
expect(details.status).toBe("aborted");
|
||||
expect(details.data).toBeUndefined();
|
||||
expect(String(details.error)).toContain("retrying forever");
|
||||
expect(abortResult.content).toEqual([{ type: "text", text: expect.stringContaining("Task aborted") }]);
|
||||
});
|
||||
|
||||
it("exposes typed last-turn mode in the argument schema", () => {
|
||||
const tool = new YieldTool(createSession());
|
||||
const parameters = tool.parameters as unknown as Record<string, unknown>;
|
||||
|
||||
Reference in New Issue
Block a user