Skip to content

Commit c730ec0

Browse files
authored
fix(agent): continue truncated model turns (#185)
## Summary - Treat provider `length` finish reasons as nonterminal model turns. - Checkpoint the partial response and continue durably with an internal instruction to split interrupted operations into smaller complete tool calls. - Validate the SDK finish-reason contract at the agent boundary and accept only `stop` as tool-free semantic completion. - Fail blocked, provider-error, and invalid terminal reasons explicitly instead of publishing incomplete work as successful. ## Root cause The production Pomodoro retry recovered its Daytona runtime correctly, but Claude reached its per-response output limit while forming the next operation. The Workflow used only the absence of parsed tool calls as its completion condition, so it finalized that truncated turn and exposed the sandbox readiness page. ## Architecture The Mastra adapter validates the six AI SDK finish reasons. Cloudflare Workflow owns the policy: tool calls execute durably; `length` appends a non-visible continuation message and starts another checkpointed model turn; only `stop` completes a tool-free run. This keeps semantic completion in charge without adding a step, token, duration, or cost ceiling. ## Decisions | Decision | Choice | Reason | |---|---|---| | Handle truncation in Workflow | Durable continuation | It preserves replay safety and applies to every provider and run type. | | Preserve partial response history | Append an internal user continuation message | The next turn sees the exact work already attempted and avoids repeating completed actions. | | Keep provider output settings unchanged | No fixed output-token override | A larger fixed cap only postpones truncation and does not establish correct completion semantics. | | Reject non-stop terminal reasons | Explicit upstream failure | Content filtering and provider errors are not successful outcomes. | ## Production evidence - Run `019fe1b3-af54-7a69-bd1d-0cd940842e41` completed after model turn 5 returned `finishReason: "length"` with no tool call. - The replacement Daytona sandbox was healthy and mounted the correct durable volume, isolating this from the sandbox recovery issue fixed in #183 and #184. ## Verification - `pnpm lint` - `pnpm typecheck` - `pnpm turbo build --force` - `pnpm deadcode` - `pnpm architecture:check` - `pnpm turbo skills:build` - production flow will be repeated after merge and exact-SHA Cloudflare deployment ## How to review 1. Review the finish-reason schema in `packages/agent-core/src/mastra/durable-agent-step.ts`. 2. Review the durable loop policy in `apps/agent-worker/src/durable-objects/agent-run-workflow.ts`. 3. Confirm the shared schema is enforced by `agent-run-workflow-runtime.ts` and the README contract matches the implementation.
1 parent 8d17949 commit c730ec0

6 files changed

Lines changed: 58 additions & 3 deletions

File tree

apps/agent-worker/README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,10 @@ the Daytona adapter normalizes the provider's structured `503` response to an in
8787
then the lifecycle verifies the active-run lease and canonical volume mount before replacing the
8888
stopped container on the same isolated workspace-volume subpath. This preserves user files while
8989
avoiding an indefinite dependency on one unhealthy runner.
90+
Provider output-length termination is nonterminal: the Workflow checkpoints the partial model turn,
91+
adds an internal continuation message, and resumes from that durable state. A tool-free turn completes
92+
the run only when the model reports an actual stop; content filtering, provider errors, and invalid
93+
terminal reasons fail explicitly instead of publishing incomplete work as success.
9094
There is no application step, token, duration, or cost ceiling; semantic completion ends the loop,
9195
while per-operation timeouts and the platform Workflow limit remain operational safeguards.
9296
The Worker pins Cloudflare's paid-plan maximum subrequest allowance because external provider,

apps/agent-worker/src/durable-objects/agent-run-workflow-runtime.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {
22
executeGeneralAgentTool,
3+
GeneralAgentFinishReasonSchema,
34
type GeneralAgentToolCall,
45
generateGeneralAgentStep,
56
} from "@cheatcode/agent-core";
@@ -87,7 +88,7 @@ export const WorkflowModelStepResultSchema = z.strictObject({
8788
input: StartRunInputSchema,
8889
logicalModelId: LogicalModelIdSchema,
8990
step: z.strictObject({
90-
finishReason: z.string(),
91+
finishReason: GeneralAgentFinishReasonSchema,
9192
responseMessages: z.array(WorkflowJsonValueSchema),
9293
text: z.string(),
9394
toolCalls: z.array(WorkflowToolCallSchema),

apps/agent-worker/src/durable-objects/agent-run-workflow.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { WorkflowEntrypoint, type WorkflowEvent, type WorkflowStep } from "cloudflare:workers";
22
import { NonRetryableError } from "cloudflare:workflows";
33
import {
4+
APIError,
45
createLogger,
56
emitErrorEvent,
67
emitUserEvent,
@@ -57,6 +58,20 @@ const STATE_STEP = stepConfig("2 minutes", 5);
5758
const CLEANUP_STEP = stepConfig("5 minutes", 5);
5859
const FAILURE_STEP = stepConfig("2 minutes", AGENT_RUN_WORKFLOW_FAILURE_RETRY_LIMIT);
5960
const JsonValueSchema = z.json();
61+
const MODEL_LENGTH_CONTINUATION_MESSAGE: ModelMessage = {
62+
role: "user",
63+
content: [
64+
{
65+
type: "text",
66+
text: [
67+
"The provider ended your previous turn at its per-response output limit.",
68+
"Continue the unfinished work from the current state without repeating completed work.",
69+
"If a tool call was interrupted, split the remaining operation into smaller complete tool calls.",
70+
"Do not claim completion until the requested outcome has been verified.",
71+
].join(" "),
72+
},
73+
],
74+
};
6075

6176
interface AgentRunWorkflowEnv extends AgentRunEnv, AgentRunWorkflowBindings {
6277
AGENT_RUN: DurableObjectNamespace<AgentRun>;
@@ -185,6 +200,11 @@ async function runAgentLoop(
185200
stepIndex,
186201
);
187202
if (model.step.toolCalls.length === 0) {
203+
if (model.step.finishReason === "length") {
204+
state = continueTruncatedModelTurn(state);
205+
continue;
206+
}
207+
requireSemanticCompletion(model.step.finishReason);
188208
await publishClosingBackstopIfNeeded(env, workflowStep, workflowInstanceId, payload, state);
189209
return;
190210
}
@@ -219,6 +239,23 @@ async function runAgentLoop(
219239
}
220240
}
221241

242+
function continueTruncatedModelTurn(state: WorkflowAgentState): WorkflowAgentState {
243+
return WorkflowAgentStateSchema.parse({
244+
...state,
245+
messages: [...state.messages, workflowJsonValue(MODEL_LENGTH_CONTINUATION_MESSAGE)],
246+
});
247+
}
248+
249+
function requireSemanticCompletion(
250+
finishReason: WorkflowModelStepResult["step"]["finishReason"],
251+
): void {
252+
if (finishReason === "stop") return;
253+
throw new APIError(502, "upstream_llm_failed", "The model stopped before completing the run.", {
254+
details: { finishReason },
255+
retriable: finishReason !== "content-filter",
256+
});
257+
}
258+
222259
async function executeModelWorkflowStep(input: {
223260
env: AgentRunWorkflowEnv;
224261
payload: AgentRunWorkflowPayload;

packages/agent-core/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ Mastra agents, tool registry, and workflow entrypoints.
77
- `createCodeRequestContext`
88
- `generateGeneralAgentStep` and `executeGeneralAgentTool` for Cloudflare
99
Workflow-owned, step-granular agent execution
10+
- `GeneralAgentFinishReasonSchema`, the validated model-turn completion contract
1011
- runtime credential and model contracts consumed by `agent-worker`
1112

1213
The tool and agent registries are statically constrained by the lightweight

packages/agent-core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export type {
1111
} from "./mastra/composio-context";
1212
export {
1313
executeGeneralAgentTool,
14+
GeneralAgentFinishReasonSchema,
1415
type GeneralAgentToolCall,
1516
generateGeneralAgentStep,
1617
} from "./mastra/durable-agent-step";

packages/agent-core/src/mastra/durable-agent-step.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,25 @@ import { z } from "zod";
44
import { mastra } from "./index";
55
import { cheatcodeTools } from "./tool-defs/tool-set";
66

7+
export const GeneralAgentFinishReasonSchema = z.enum([
8+
"stop",
9+
"length",
10+
"content-filter",
11+
"tool-calls",
12+
"error",
13+
"other",
14+
]);
15+
16+
type GeneralAgentFinishReason = z.infer<typeof GeneralAgentFinishReasonSchema>;
17+
718
export interface GeneralAgentToolCall {
819
input: JSONValue;
920
toolCallId: string;
1021
toolName: string;
1122
}
1223

1324
interface GeneralAgentStepResult {
14-
finishReason: string;
25+
finishReason: GeneralAgentFinishReason;
1526
responseMessages: JSONValue[];
1627
text: string;
1728
toolCalls: GeneralAgentToolCall[];
@@ -51,7 +62,7 @@ export async function generateGeneralAgentStep(
5162
runId: options.runId,
5263
});
5364
return {
54-
finishReason: result.finishReason ?? "unknown",
65+
finishReason: GeneralAgentFinishReasonSchema.parse(result.finishReason),
5566
responseMessages: toJsonValues(result.response.messages ?? []),
5667
text: result.text,
5768
toolCalls: result.toolCalls.map((call) => ({

0 commit comments

Comments
 (0)