diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 04ae4eb4d..159c23f94 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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 diff --git a/packages/coding-agent/src/tools/yield.ts b/packages/coding-agent/src/tools/yield.ts index 184645d94..e60685060 100644 --- a/packages/coding-agent/src/tools/yield.ts +++ b/packages/coding-agent/src/tools/yield.ts @@ -198,6 +198,14 @@ function wrapYieldParameters(dataSchema: Record): Record { readonly name = "yield"; readonly approval = "read" as const; @@ -217,6 +225,7 @@ export class YieldTool implements AgentTool { #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 { 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": } }` 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: }}` for success or `{result: {error: "message"}}` for failure.', + `result must contain either \`data\` or \`error\`. Use \`{result: {data: }}\` 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 { } } + this.#emptyResultFailures = 0; const responseText = status === "aborted" ? `Task aborted: ${errorMessage}` diff --git a/packages/coding-agent/test/tools/yield.test.ts b/packages/coding-agent/test/tools/yield.test.ts index bca9213d2..3a88a2484 100644 --- a/packages/coding-agent/test/tools/yield.test.ts +++ b/packages/coding-agent/test/tools/yield.test.ts @@ -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: }}` 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: }}` 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;