diff --git a/apps/agent-worker/README.md b/apps/agent-worker/README.md index 0f0d207..b2a9aa9 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 f815394..745c3e9 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 fec0a61..2ec70b6 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 a70b5a9..c245b4f 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 6fb5349..a396828 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 6db43b7..37dd37d 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) => ({