From 9fc83bc57a98279c5495211215e8b34206d666cb Mon Sep 17 00:00:00 2001 From: HiranoMasaaki Date: Wed, 18 Feb 2026 08:16:32 +0000 Subject: [PATCH] fix: retry when LLM generates empty text in GeneratingRunResult The GeneratingRunResult state did not handle the case where the LLM returns empty text after attemptCompletion. This caused completeRun events with text: "" which is a bug. Now retries with a prompt asking for a text response, matching the pattern in GeneratingToolCall. Co-Authored-By: Claude Opus 4.6 --- .changeset/fix-empty-run-result.md | 5 ++ .../states/generating-run-result.test.ts | 54 ++++++++++++++----- .../states/generating-run-result.ts | 31 +++++++++++ 3 files changed, 77 insertions(+), 13 deletions(-) create mode 100644 .changeset/fix-empty-run-result.md diff --git a/.changeset/fix-empty-run-result.md b/.changeset/fix-empty-run-result.md new file mode 100644 index 00000000..80ef2e23 --- /dev/null +++ b/.changeset/fix-empty-run-result.md @@ -0,0 +1,5 @@ +--- +"@perstack/runtime": patch +--- + +Fix empty completeRun text by retrying when LLM generates no visible text in GeneratingRunResult diff --git a/packages/runtime/src/state-machine/states/generating-run-result.test.ts b/packages/runtime/src/state-machine/states/generating-run-result.test.ts index 5482bb34..508a0212 100644 --- a/packages/runtime/src/state-machine/states/generating-run-result.test.ts +++ b/packages/runtime/src/state-machine/states/generating-run-result.test.ts @@ -302,7 +302,7 @@ describe("@perstack/runtime: StateMachineLogic['GeneratingRunResult']", () => { } }) - it("handles undefined text from LLM by using empty string", async () => { + it("retries when LLM generates empty text", async () => { const setting = createRunSetting() const checkpoint = createCheckpoint() const step = createStep({ @@ -332,18 +332,46 @@ describe("@perstack/runtime: StateMachineLogic['GeneratingRunResult']", () => { skillManager: createMockSkillManagerFromAdapters({}), llmExecutor: mockLLMExecutor as unknown as LLMExecutor, }) - expect(event.type).toBe("completeRun") - if (event.type === "completeRun") { - expect(event.text).toBeUndefined() - const lastMessage = event.checkpoint.messages[event.checkpoint.messages.length - 1] - expect(lastMessage.type).toBe("expertMessage") - if (lastMessage.type === "expertMessage") { - const textPart = lastMessage.contents.find((c) => c.type === "textPart") - expect(textPart).toBeDefined() - if (textPart?.type === "textPart") { - expect(textPart.text).toBe("") - } - } + expect(event.type).toBe("retry") + if (event.type === "retry") { + expect(event.reason).toContain("No text generated") + } + }) + + it("returns stopRunByError when empty text retries exceed maxRetries", async () => { + const setting = createRunSetting({ maxRetries: 2 }) + const checkpoint = createCheckpoint({ retryCount: 2 }) + const step = createStep({ + toolCalls: [ + { + id: "tc_123", + skillName: "@perstack/base", + toolName: "attemptCompletion", + args: {}, + }, + ], + toolResults: [ + { + id: "tc_123", + skillName: "@perstack/base", + toolName: "attemptCompletion", + result: [{ type: "textPart", text: JSON.stringify({}), id: createId() }], + }, + ], + }) + mockLLMExecutor.setMockResult(createMockResult(undefined)) + const event = await StateMachineLogics.GeneratingRunResult({ + setting, + checkpoint, + step, + eventListener: async () => {}, + skillManager: createMockSkillManagerFromAdapters({}), + llmExecutor: mockLLMExecutor as unknown as LLMExecutor, + }) + expect(event.type).toBe("stopRunByError") + if (event.type === "stopRunByError") { + expect(event.error.name).toBe("EmptyRunResult") + expect(event.error.message).toContain("No text generated for run result") } }) }) diff --git a/packages/runtime/src/state-machine/states/generating-run-result.ts b/packages/runtime/src/state-machine/states/generating-run-result.ts index 4c2dec83..a1a37a7c 100644 --- a/packages/runtime/src/state-machine/states/generating-run-result.ts +++ b/packages/runtime/src/state-machine/states/generating-run-result.ts @@ -144,6 +144,37 @@ export async function generatingRunResultLogic({ const usage = usageFromGenerateTextResult(generationResult) const { text, reasoning } = generationResult + // Empty text = retry (LLM generated no visible text, possibly only reasoning/thinking) + if (!text) { + const currentRetryCount = checkpoint.retryCount ?? 0 + if (currentRetryCount >= setting.maxRetries) { + return stopRunByError(setting, checkpoint, { + checkpoint: { + ...checkpoint, + status: "stoppedByError", + }, + step: { + ...step, + finishedAt: Date.now(), + }, + error: { + name: "EmptyRunResult", + message: `Max retries (${setting.maxRetries}) exceeded: No text generated for run result`, + isRetryable: false, + }, + }) + } + const reason = JSON.stringify({ + error: "Error: No text generated", + message: "You must provide a text response summarizing the result. Try again.", + }) + return retry(setting, checkpoint, { + reason, + newMessages: [toolMessage, createUserMessage([{ type: "textPart", text: reason }])], + usage, + }) + } + // Extract thinking from reasoning (Anthropic, Google) const thinkingParts = extractThinkingParts(reasoning as ReasoningPart[] | undefined) const thinkingText = extractThinkingText(reasoning as ReasoningPart[] | undefined)