Skip to content

Commit 5fb2bac

Browse files
authored
fix(agent): prevent fourth-tool workflow failure (#181)
## Summary - Fixes the deterministic fourth-tool crash caused by validating 16 rotated skill capabilities before trimming the persisted set to its 12-entry bound. - Routes projectless imperatives such as “build a nice pomodoro app” through the managed web app-builder unless the prompt explicitly selects mobile or a non-web runtime. - Adds run-scoped, redacted error telemetry and recovers known structured error codes across workflow error wrappers. ## Root cause Every checkpointed tool step minted four independently scoped runtime capabilities. The fourth step combined twelve retained capabilities with four new ones, validated the sixteen-entry intermediate array against a twelve-entry schema, and failed before the requested shell command ran. Workflow retries repeated the same deterministic validation failure. ## Architecture Capability inputs are validated independently, then the retained-plus-new set is trimmed to the durable storage bound before final validation and persistence. App-builder inference remains a narrow projectless fallback: explicit mobile signals win, explicit web signals follow, and a generic app defaults to web unless a non-web runtime is named. Workflow terminal and pre-tool infrastructure errors now emit only allowlisted categorical metadata. Prompts, commands, provider responses, stack traces, and credentials remain excluded. ## Decisions made | Decision | Choice | Reasoning | |---|---|---| | Capability rotation | Validate new values, trim combined values, validate stored result | Preserves strict boundary validation without rejecting the temporary overlap that the bound exists to prune. | | Generic app routing | Default imperative `app` requests to managed web app-builder | Matches normal user language while explicit mobile and non-web signals remain authoritative. | | Error transport | Add a canonical Zod error-code schema and safe wrapper recovery | Prevents known error classifications from collapsing solely because a platform boundary changes the Error prototype. | | Failure telemetry | Emit run-scoped safe classifications at pre-tool and terminal boundaries | Makes exhausted retries diagnosable without retaining sensitive content. | ## Edge cases handled - Explicit mobile prompts continue to use the mobile builder. - CLI, backend, API, desktop, Electron, terminal, server, and library prompts remain on the general path unless an explicit web signal is present. - Invalid new capabilities cannot be hidden by trimming because the incoming set is validated first. - The unrelated `dogfood-output/` working directory is not included. ## Verification - [x] `pnpm lint` - [x] `pnpm typecheck` - [x] `pnpm turbo build --force` - [x] `pnpm deadcode` - [x] `pnpm architecture:check` - [x] `pnpm turbo skills:build` - [ ] Deploy the merged SHA to production - [ ] Exercise “build a nice pomodoro app” on production through scaffold, four-plus tool calls, dev-server start, and live preview
1 parent 13c7c9a commit 5fb2bac

9 files changed

Lines changed: 201 additions & 81 deletions

File tree

apps/agent-worker/README.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -122,10 +122,12 @@ streams. The Workflow controller owns admission, callback identity, and cancella
122122
retains only durable run identity, status, transcript, cancellation, and dependency wiring.
123123

124124
An explicit app-builder mode remains authoritative. In a projectless chat, a narrowly
125-
matched imperative such as “build a website” or “create a mobile app” also enters the matching
126-
app-builder path before model execution. That high-confidence fallback materializes the project,
127-
scaffolds its canonical workspace, and registers the managed preview even when the selected model
128-
would otherwise attempt generic shell work and finish without a Computer target.
125+
matched imperative such as “build a website,” “create a pomodoro app,” or “create a mobile app”
126+
also enters the matching app-builder path before model execution. A generic app defaults to the
127+
web path unless the request carries an explicit mobile or non-web runtime signal. That
128+
high-confidence fallback materializes the project, scaffolds its canonical workspace, and
129+
registers the managed preview even when the selected model would otherwise attempt generic shell
130+
work and finish without a Computer target.
129131
The starter page is an internal server-readiness target, not user-facing generated content. A
130132
fresh template run emits the typed `app-preview-status` transition from `building` to `ready` only
131133
after model execution (and the mobile preview restart, when applicable), so the web client can keep

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

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { APIError } from "@cheatcode/observability";
2-
import type { ErrorCode } from "@cheatcode/types";
1+
import { APIError, safeErrorTelemetry } from "@cheatcode/observability";
2+
import { type ErrorCode, ErrorCodeSchema } from "@cheatcode/types";
33

44
export interface AgentRunStreamError {
55
code: ErrorCode;
@@ -16,9 +16,41 @@ export function toAgentRunStreamError(error: unknown): AgentRunStreamError {
1616
};
1717
}
1818

19+
const telemetry = safeErrorTelemetry(error);
20+
const code = errorCodeFromTelemetry(telemetry.sourceErrorCode, telemetry.causeCode);
21+
if (code) {
22+
return {
23+
code,
24+
message: workflowFailureMessage(code),
25+
retriable: telemetry.retriable ?? telemetry.causeRetriable ?? true,
26+
};
27+
}
28+
1929
return {
2030
code: "tool_execution_failed",
2131
message: "Agent run failed unexpectedly",
2232
retriable: true,
2333
};
2434
}
35+
36+
function errorCodeFromTelemetry(...candidates: Array<string | undefined>): ErrorCode | undefined {
37+
for (const candidate of candidates) {
38+
const parsed = ErrorCodeSchema.safeParse(candidate);
39+
if (parsed.success) return parsed.data;
40+
}
41+
return undefined;
42+
}
43+
44+
function workflowFailureMessage(code: ErrorCode): string {
45+
if (
46+
code === "sandbox_start_failed" ||
47+
code === "upstream_sandbox_failed" ||
48+
code === "upstream_sandbox_timeout"
49+
) {
50+
return "The computer could not complete this operation";
51+
}
52+
if (code === "internal_service_error" || code === "service_maintenance_unavailable") {
53+
return "The agent service could not complete this run";
54+
}
55+
return "Agent run failed before it could finish";
56+
}

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,9 @@ const IMPERATIVE_BUILD_PATTERN =
9797
const MOBILE_APP_PATTERN = /\b(?:mobile app|expo|react native|ios app|android app|iphone app)\b/iu;
9898
const WEB_APP_PATTERN =
9999
/\b(?:web ?app|website|web ?site|landing ?page|home ?page|web ?page|dashboard|next\.?js|frontend|front-end|saas)\b/iu;
100+
const GENERIC_APP_PATTERN = /\b(?:app|application)\b/iu;
101+
const NON_WEB_APP_PATTERN =
102+
/\b(?:api|backend|cli|command[ -]line|desktop|electron|library|macos|server|terminal|windows)\b/iu;
100103

101104
function appBuilderModeForRun(input: StartRunInput): "app-builder" | "app-builder-mobile" | null {
102105
if (isAppBuilderMode(input.projectMode)) {
@@ -108,5 +111,10 @@ function appBuilderModeForRun(input: StartRunInput): "app-builder" | "app-builde
108111
if (MOBILE_APP_PATTERN.test(input.messageText)) {
109112
return "app-builder-mobile";
110113
}
111-
return WEB_APP_PATTERN.test(input.messageText) ? "app-builder" : null;
114+
if (WEB_APP_PATTERN.test(input.messageText)) {
115+
return "app-builder";
116+
}
117+
return GENERIC_APP_PATTERN.test(input.messageText) && !NON_WEB_APP_PATTERN.test(input.messageText)
118+
? "app-builder"
119+
: null;
112120
}

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

Lines changed: 58 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,13 @@ import {
33
type GeneralAgentToolCall,
44
generateGeneralAgentStep,
55
} from "@cheatcode/agent-core";
6-
import { createLogger, emitUserEvent, readBoundedResponseJson } from "@cheatcode/observability";
6+
import {
7+
createLogger,
8+
emitErrorEvent,
9+
emitUserEvent,
10+
readBoundedResponseJson,
11+
safeErrorTelemetry,
12+
} from "@cheatcode/observability";
713
import type { ArtifactRuntime, WorkspaceResolver } from "@cheatcode/sandbox-contracts";
814
import {
915
FALLBACK_MODEL_ID,
@@ -21,6 +27,7 @@ import { storeAgentArtifact } from "./agent-run-artifacts";
2127
import { loadThreadModelContext } from "./agent-run-conversation";
2228
import { restoreReferencedDeliverables } from "./agent-run-deliverables";
2329
import type { AgentRunEnv } from "./agent-run-env";
30+
import { toAgentRunStreamError } from "./agent-run-errors";
2431
import {
2532
createAgentRequestContext,
2633
type MastraContextOptions,
@@ -211,24 +218,56 @@ export async function executeWorkflowToolStep(
211218
userId: input.userId,
212219
});
213220
const sandbox = sandboxFor(env, input);
214-
await sandbox.renewRun(input.runId);
215-
await stub.waitForBrowserTakeover(callback);
216-
return guardSkillRuntimeCapabilities({
217-
env,
218-
logger,
219-
operation: () =>
220-
executePreparedWorkflowTool({
221-
callback,
222-
env,
223-
input,
224-
logger,
225-
sandbox,
226-
selectedLogicalModelId: state.selectedLogicalModelId,
227-
stub,
228-
toolCall,
229-
}),
230-
run: input,
231-
sandbox,
221+
try {
222+
await sandbox.renewRun(input.runId);
223+
await stub.waitForBrowserTakeover(callback);
224+
return await guardSkillRuntimeCapabilities({
225+
env,
226+
logger,
227+
operation: () =>
228+
executePreparedWorkflowTool({
229+
callback,
230+
env,
231+
input,
232+
logger,
233+
sandbox,
234+
selectedLogicalModelId: state.selectedLogicalModelId,
235+
stub,
236+
toolCall,
237+
}),
238+
run: input,
239+
sandbox,
240+
});
241+
} catch (error) {
242+
recordToolStepInfrastructureFailure(env, input, toolCall, error, logger);
243+
throw error;
244+
}
245+
}
246+
247+
function recordToolStepInfrastructureFailure(
248+
env: AgentRunEnv,
249+
input: StartRunInput,
250+
toolCall: GeneralAgentToolCall,
251+
error: unknown,
252+
logger: ReturnType<typeof createLogger>,
253+
): void {
254+
const failure = toAgentRunStreamError(error);
255+
const telemetry = safeErrorTelemetry(error);
256+
emitErrorEvent(env, {
257+
errorCategory: "agent_run_tool_step",
258+
errorCode: failure.code,
259+
route: "agent-run-workflow/tool-step",
260+
runId: input.runId,
261+
userId: input.userId,
262+
workerName: "agent-worker",
263+
...(env.CHEATCODE_RELEASE_SHA ? { versionTag: env.CHEATCODE_RELEASE_SHA } : {}),
264+
...telemetry,
265+
});
266+
logger.error("agent_run_tool_step_failed", {
267+
failureCode: failure.code,
268+
toolCallId: toolCall.toolCallId,
269+
toolName: toolCall.toolName,
270+
...telemetry,
232271
});
233272
}
234273

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

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
import { WorkflowEntrypoint, type WorkflowEvent, type WorkflowStep } from "cloudflare:workers";
22
import { NonRetryableError } from "cloudflare:workflows";
33
import {
4+
createLogger,
5+
emitErrorEvent,
46
emitUserEvent,
57
readBoundedResponseJson,
68
readBoundedResponseText,
9+
safeErrorTelemetry,
710
} from "@cheatcode/observability";
811
import type { ModelMessage, ToolResultPart, UIMessageChunk } from "ai";
912
import { z } from "zod";
@@ -92,6 +95,7 @@ export class AgentRunWorkflow extends WorkflowEntrypoint<
9295
return { status: "completed" };
9396
} catch (error) {
9497
const failure = toAgentRunStreamError(error);
98+
recordWorkflowFailure(this.env, payload, event.instanceId, failure, error);
9599
await step.do("fail AgentRun", FAILURE_STEP, () =>
96100
failAgentRun(this.env, event.instanceId, payload, failure),
97101
);
@@ -104,6 +108,35 @@ export class AgentRunWorkflow extends WorkflowEntrypoint<
104108
}
105109
}
106110

111+
function recordWorkflowFailure(
112+
env: AgentRunWorkflowEnv,
113+
payload: AgentRunWorkflowPayload,
114+
workflowInstanceId: string,
115+
failure: ReturnType<typeof toAgentRunStreamError>,
116+
error: unknown,
117+
): void {
118+
const telemetry = safeErrorTelemetry(error);
119+
emitErrorEvent(env, {
120+
errorCategory: "agent_run_workflow",
121+
errorCode: failure.code,
122+
route: "agent-run-workflow",
123+
runId: payload.input.runId,
124+
userId: payload.input.userId,
125+
workerName: "agent-worker",
126+
...(env.CHEATCODE_RELEASE_SHA ? { versionTag: env.CHEATCODE_RELEASE_SHA } : {}),
127+
...telemetry,
128+
});
129+
createLogger({
130+
runId: payload.input.runId,
131+
threadId: payload.input.threadId,
132+
userId: payload.input.userId,
133+
}).error("agent_run_workflow_failed", {
134+
failureCode: failure.code,
135+
workflowInstanceId,
136+
...telemetry,
137+
});
138+
}
139+
107140
export async function admitAgentRunWorkflow(
108141
env: AgentRunWorkflowBindings,
109142
payload: AgentRunWorkflowPayload,

packages/db/src/skill-runtime-capabilities.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -54,10 +54,10 @@ export async function rotateSkillRuntimeCapabilities(
5454
const retained = StoredSkillRuntimeCapabilitiesSchema.parse(run.capabilities).filter(
5555
(capability) => capability.expiresAt > input.now,
5656
);
57-
const capabilities = StoredSkillRuntimeCapabilitiesSchema.parse([
58-
...retained,
59-
...input.capabilities,
60-
]).slice(-MAX_STORED_CAPABILITIES_PER_RUN);
57+
const next = StoredSkillRuntimeCapabilitiesSchema.parse(input.capabilities);
58+
const capabilities = StoredSkillRuntimeCapabilitiesSchema.parse(
59+
[...retained, ...next].slice(-MAX_STORED_CAPABILITIES_PER_RUN),
60+
);
6161
const [updated] = await tx
6262
.update(agentRuns)
6363
.set({ skillRuntimeCapabilities: capabilities })

packages/observability/README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@ Error Analytics Engine rows intentionally contain only categorical metadata.
4141
Raw error messages and stack traces are never written to Analytics Engine, and
4242
the structured logger suppresses error-message, stack, SQL, parameter, body,
4343
prompt, content, and command-output fields at its sink.
44+
Agent Workflow terminal failures and pre-tool infrastructure failures emit run-scoped rows through
45+
this same safe projection, so an exhausted retry retains source and cause classifications without
46+
persisting prompts, commands, provider responses, or credentials.
4447

4548
## Code Checks
4649

packages/types/src/errors.ts

Lines changed: 53 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,58 +1,61 @@
11
import { z } from "zod";
22

3-
export type ErrorCode =
4-
| "auth_token_missing"
5-
| "auth_token_invalid"
6-
| "auth_token_expired"
7-
| "payment_method_required"
8-
| "payment_method_failed"
9-
| "subscription_past_due"
10-
| "permission_access_denied"
11-
| "permission_plan_required"
12-
| "resource_user_not_found"
13-
| "resource_project_not_found"
14-
| "resource_thread_not_found"
15-
| "resource_run_not_found"
16-
| "resource_output_not_found"
17-
| "resource_tool_not_found"
18-
| "resource_skill_not_found"
19-
| "request_body_invalid"
20-
| "request_query_param_invalid"
21-
| "request_path_param_invalid"
22-
| "validation_model_unavailable"
23-
| "validation_tool_not_registered"
24-
| "idempotency_key_reused"
25-
| "validation_byok_required"
26-
| "conflict_request_in_flight"
27-
| "conflict_run_already_active"
28-
| "conflict_state_invalid"
29-
| "rate_limit_exceeded"
30-
| "quota_sandbox_hours_exhausted"
31-
| "quota_composio_calls_exhausted"
32-
| "byok_key_missing"
33-
| "byok_key_invalid"
34-
| "byok_key_quota_exhausted"
35-
| "sandbox_disk_full"
36-
| "sandbox_cpu_exhausted"
37-
| "sandbox_start_failed"
38-
| "sandbox_command_failed"
39-
| "sandbox_process_limit_reached"
40-
| "tool_validation_failed"
41-
| "tool_execution_failed"
42-
| "tool_execution_timeout"
43-
| "upstream_llm_overloaded"
44-
| "upstream_llm_failed"
45-
| "upstream_llm_timeout"
46-
| "upstream_sandbox_failed"
47-
| "upstream_sandbox_timeout"
48-
| "upstream_provider_outage"
49-
| "repo_import_failed"
50-
| "internal_service_error"
51-
| "service_maintenance_unavailable";
3+
export const ErrorCodeSchema = z.enum([
4+
"auth_token_missing",
5+
"auth_token_invalid",
6+
"auth_token_expired",
7+
"payment_method_required",
8+
"payment_method_failed",
9+
"subscription_past_due",
10+
"permission_access_denied",
11+
"permission_plan_required",
12+
"resource_user_not_found",
13+
"resource_project_not_found",
14+
"resource_thread_not_found",
15+
"resource_run_not_found",
16+
"resource_output_not_found",
17+
"resource_tool_not_found",
18+
"resource_skill_not_found",
19+
"request_body_invalid",
20+
"request_query_param_invalid",
21+
"request_path_param_invalid",
22+
"validation_model_unavailable",
23+
"validation_tool_not_registered",
24+
"idempotency_key_reused",
25+
"validation_byok_required",
26+
"conflict_request_in_flight",
27+
"conflict_run_already_active",
28+
"conflict_state_invalid",
29+
"rate_limit_exceeded",
30+
"quota_sandbox_hours_exhausted",
31+
"quota_composio_calls_exhausted",
32+
"byok_key_missing",
33+
"byok_key_invalid",
34+
"byok_key_quota_exhausted",
35+
"sandbox_disk_full",
36+
"sandbox_cpu_exhausted",
37+
"sandbox_start_failed",
38+
"sandbox_command_failed",
39+
"sandbox_process_limit_reached",
40+
"tool_validation_failed",
41+
"tool_execution_failed",
42+
"tool_execution_timeout",
43+
"upstream_llm_overloaded",
44+
"upstream_llm_failed",
45+
"upstream_llm_timeout",
46+
"upstream_sandbox_failed",
47+
"upstream_sandbox_timeout",
48+
"upstream_provider_outage",
49+
"repo_import_failed",
50+
"internal_service_error",
51+
"service_maintenance_unavailable",
52+
]);
53+
54+
export type ErrorCode = z.infer<typeof ErrorCodeSchema>;
5255

5356
export const ErrorResponseSchema = z.strictObject({
5457
error: z.strictObject({
55-
code: z.string(),
58+
code: ErrorCodeSchema,
5659
message: z.string(),
5760
hint: z.string().optional(),
5861
retriable: z.boolean(),

packages/types/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ export {
3131
} from "./billing";
3232
export type { AgentCapabilityName, ToolCapabilityName } from "./capabilities";
3333
export type { ErrorCode } from "./errors";
34-
export { ErrorResponseSchema } from "./errors";
34+
export { ErrorCodeSchema, ErrorResponseSchema } from "./errors";
3535
export type { AgentRunId, ProjectId, ThreadId, UserId } from "./ids";
3636
export { toAgentRunId, toProjectId, toThreadId, toUserId } from "./ids";
3737
export type { IntegrationName } from "./integrations";

0 commit comments

Comments
 (0)