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:
roboomp
2026-07-10 15:49:56 +00:00
parent a0f7266fbc
commit a34d99c6a8
3 changed files with 84 additions and 1 deletions
+4
View File
@@ -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
+29 -1
View File
@@ -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>;