Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-empty-run-result.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@perstack/runtime": patch
---

Fix empty completeRun text by retrying when LLM generates no visible text in GeneratingRunResult
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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")
}
})
})
31 changes: 31 additions & 0 deletions packages/runtime/src/state-machine/states/generating-run-result.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down