From 4534dbdc88fe6f9c24e1a4dde0bcc1ead542d95e Mon Sep 17 00:00:00 2001 From: iamjr15 Date: Sat, 8 Aug 2026 19:49:23 +0530 Subject: [PATCH] fix(agent): continue truncated model turns Treat provider output-length termination as nonterminal durable state. Validate finish reasons at the agent boundary and complete only after a semantic stop. --- apps/agent-worker/README.md | 4 ++ .../agent-run-workflow-runtime.ts | 3 +- .../src/durable-objects/agent-run-workflow.ts | 37 +++++++++++++++++++ packages/agent-core/README.md | 1 + packages/agent-core/src/index.ts | 1 + .../src/mastra/durable-agent-step.ts | 15 +++++++- 6 files changed, 58 insertions(+), 3 deletions(-) diff --git a/apps/agent-worker/README.md b/apps/agent-worker/README.md index 0f0d207a..b2a9aa9b 100644 --- a/apps/agent-worker/README.md +++ b/apps/agent-worker/README.md @@ -87,6 +87,10 @@ the Daytona adapter normalizes the provider's structured `503` response to an in then the lifecycle verifies the active-run lease and canonical volume mount before replacing the stopped container on the same isolated workspace-volume subpath. This preserves user files while avoiding an indefinite dependency on one unhealthy runner. +Provider output-length termination is nonterminal: the Workflow checkpoints the partial model turn, +adds an internal continuation message, and resumes from that durable state. A tool-free turn completes +the run only when the model reports an actual stop; content filtering, provider errors, and invalid +terminal reasons fail explicitly instead of publishing incomplete work as success. There is no application step, token, duration, or cost ceiling; semantic completion ends the loop, while per-operation timeouts and the platform Workflow limit remain operational safeguards. The Worker pins Cloudflare's paid-plan maximum subrequest allowance because external provider, diff --git a/apps/agent-worker/src/durable-objects/agent-run-workflow-runtime.ts b/apps/agent-worker/src/durable-objects/agent-run-workflow-runtime.ts index f815394e..745c3e93 100644 --- a/apps/agent-worker/src/durable-objects/agent-run-workflow-runtime.ts +++ b/apps/agent-worker/src/durable-objects/agent-run-workflow-runtime.ts @@ -1,5 +1,6 @@ import { executeGeneralAgentTool, + GeneralAgentFinishReasonSchema, type GeneralAgentToolCall, generateGeneralAgentStep, } from "@cheatcode/agent-core"; @@ -87,7 +88,7 @@ export const WorkflowModelStepResultSchema = z.strictObject({ input: StartRunInputSchema, logicalModelId: LogicalModelIdSchema, step: z.strictObject({ - finishReason: z.string(), + finishReason: GeneralAgentFinishReasonSchema, responseMessages: z.array(WorkflowJsonValueSchema), text: z.string(), toolCalls: z.array(WorkflowToolCallSchema), diff --git a/apps/agent-worker/src/durable-objects/agent-run-workflow.ts b/apps/agent-worker/src/durable-objects/agent-run-workflow.ts index fec0a612..2ec70b64 100644 --- a/apps/agent-worker/src/durable-objects/agent-run-workflow.ts +++ b/apps/agent-worker/src/durable-objects/agent-run-workflow.ts @@ -1,6 +1,7 @@ import { WorkflowEntrypoint, type WorkflowEvent, type WorkflowStep } from "cloudflare:workers"; import { NonRetryableError } from "cloudflare:workflows"; import { + APIError, createLogger, emitErrorEvent, emitUserEvent, @@ -57,6 +58,20 @@ const STATE_STEP = stepConfig("2 minutes", 5); const CLEANUP_STEP = stepConfig("5 minutes", 5); const FAILURE_STEP = stepConfig("2 minutes", AGENT_RUN_WORKFLOW_FAILURE_RETRY_LIMIT); const JsonValueSchema = z.json(); +const MODEL_LENGTH_CONTINUATION_MESSAGE: ModelMessage = { + role: "user", + content: [ + { + type: "text", + text: [ + "The provider ended your previous turn at its per-response output limit.", + "Continue the unfinished work from the current state without repeating completed work.", + "If a tool call was interrupted, split the remaining operation into smaller complete tool calls.", + "Do not claim completion until the requested outcome has been verified.", + ].join(" "), + }, + ], +}; interface AgentRunWorkflowEnv extends AgentRunEnv, AgentRunWorkflowBindings { AGENT_RUN: DurableObjectNamespace; @@ -185,6 +200,11 @@ async function runAgentLoop( stepIndex, ); if (model.step.toolCalls.length === 0) { + if (model.step.finishReason === "length") { + state = continueTruncatedModelTurn(state); + continue; + } + requireSemanticCompletion(model.step.finishReason); await publishClosingBackstopIfNeeded(env, workflowStep, workflowInstanceId, payload, state); return; } @@ -219,6 +239,23 @@ async function runAgentLoop( } } +function continueTruncatedModelTurn(state: WorkflowAgentState): WorkflowAgentState { + return WorkflowAgentStateSchema.parse({ + ...state, + messages: [...state.messages, workflowJsonValue(MODEL_LENGTH_CONTINUATION_MESSAGE)], + }); +} + +function requireSemanticCompletion( + finishReason: WorkflowModelStepResult["step"]["finishReason"], +): void { + if (finishReason === "stop") return; + throw new APIError(502, "upstream_llm_failed", "The model stopped before completing the run.", { + details: { finishReason }, + retriable: finishReason !== "content-filter", + }); +} + async function executeModelWorkflowStep(input: { env: AgentRunWorkflowEnv; payload: AgentRunWorkflowPayload; diff --git a/packages/agent-core/README.md b/packages/agent-core/README.md index a70b5a91..c245b4f7 100644 --- a/packages/agent-core/README.md +++ b/packages/agent-core/README.md @@ -7,6 +7,7 @@ Mastra agents, tool registry, and workflow entrypoints. - `createCodeRequestContext` - `generateGeneralAgentStep` and `executeGeneralAgentTool` for Cloudflare Workflow-owned, step-granular agent execution +- `GeneralAgentFinishReasonSchema`, the validated model-turn completion contract - runtime credential and model contracts consumed by `agent-worker` The tool and agent registries are statically constrained by the lightweight diff --git a/packages/agent-core/src/index.ts b/packages/agent-core/src/index.ts index 6fb53495..a3968280 100644 --- a/packages/agent-core/src/index.ts +++ b/packages/agent-core/src/index.ts @@ -11,6 +11,7 @@ export type { } from "./mastra/composio-context"; export { executeGeneralAgentTool, + GeneralAgentFinishReasonSchema, type GeneralAgentToolCall, generateGeneralAgentStep, } from "./mastra/durable-agent-step"; diff --git a/packages/agent-core/src/mastra/durable-agent-step.ts b/packages/agent-core/src/mastra/durable-agent-step.ts index 6db43b7b..37dd37dd 100644 --- a/packages/agent-core/src/mastra/durable-agent-step.ts +++ b/packages/agent-core/src/mastra/durable-agent-step.ts @@ -4,6 +4,17 @@ import { z } from "zod"; import { mastra } from "./index"; import { cheatcodeTools } from "./tool-defs/tool-set"; +export const GeneralAgentFinishReasonSchema = z.enum([ + "stop", + "length", + "content-filter", + "tool-calls", + "error", + "other", +]); + +type GeneralAgentFinishReason = z.infer; + export interface GeneralAgentToolCall { input: JSONValue; toolCallId: string; @@ -11,7 +22,7 @@ export interface GeneralAgentToolCall { } interface GeneralAgentStepResult { - finishReason: string; + finishReason: GeneralAgentFinishReason; responseMessages: JSONValue[]; text: string; toolCalls: GeneralAgentToolCall[]; @@ -51,7 +62,7 @@ export async function generateGeneralAgentStep( runId: options.runId, }); return { - finishReason: result.finishReason ?? "unknown", + finishReason: GeneralAgentFinishReasonSchema.parse(result.finishReason), responseMessages: toJsonValues(result.response.messages ?? []), text: result.text, toolCalls: result.toolCalls.map((call) => ({