From 7e255e4a2179c9feb5aa383bc4f566d1dbbe8ac1 Mon Sep 17 00:00:00 2001 From: Toby V Date: Sat, 27 Jun 2026 15:40:35 +1000 Subject: [PATCH 1/2] fix(api): only send adaptive thinking on models that support it The api backend hardcoded thinking:{type:adaptive}, which 400s on Haiku 4.5 / Sonnet 4.5 / older ('adaptive thinking is not supported on this model'). Now gated by supportsAdaptiveThinking(model); omitted for unsupported models. Surfaced by running with ANTHROPIC_MODEL=claude-haiku-4-5. Regression test added. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/agents/src/backends/api.ts | 7 +++++-- packages/agents/src/prompt.test.ts | 15 +++++++++++++++ packages/agents/src/prompt.ts | 16 ++++++++++++++++ 3 files changed, 36 insertions(+), 2 deletions(-) create mode 100644 packages/agents/src/prompt.test.ts diff --git a/packages/agents/src/backends/api.ts b/packages/agents/src/backends/api.ts index c11c5c7..42f2afa 100644 --- a/packages/agents/src/backends/api.ts +++ b/packages/agents/src/backends/api.ts @@ -2,7 +2,7 @@ import Anthropic from "@anthropic-ai/sdk" import type { WorkerInput, WorkerResult } from "@eng/core" import type { WorkerBackend } from "../claude-worker" import { affordableMaxTokens, costCentsFromUsage } from "../pricing" -import { buildSystemPrompt } from "../prompt" +import { buildSystemPrompt, supportsAdaptiveThinking } from "../prompt" /** Worker backend that calls the Anthropic Messages API directly via `@anthropic-ai/sdk`. */ export class ApiBackend implements WorkerBackend { @@ -18,7 +18,10 @@ export class ApiBackend implements WorkerBackend { { model: this.model, max_tokens: affordableMaxTokens(this.model, input.budgetCentsRemaining, 16000), - thinking: { type: "adaptive" }, + // Adaptive thinking only where the model supports it — Haiku 4.5 / older 400 on it. + ...(supportsAdaptiveThinking(this.model) + ? { thinking: { type: "adaptive" as const } } + : {}), system: buildSystemPrompt(input), messages: [{ role: "user", content: input.task }], // Structured outputs (ENG-009): constrain the model to schema-conforming JSON when a schema diff --git a/packages/agents/src/prompt.test.ts b/packages/agents/src/prompt.test.ts new file mode 100644 index 0000000..f9d2b4d --- /dev/null +++ b/packages/agents/src/prompt.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest" +import { supportsAdaptiveThinking } from "./prompt" + +describe("supportsAdaptiveThinking", () => { + it("is true for the adaptive-thinking family (Opus 4.6+/Sonnet 4.6/Fable)", () => { + expect(supportsAdaptiveThinking("claude-opus-4-8")).toBe(true) + expect(supportsAdaptiveThinking("claude-sonnet-4-6")).toBe(true) + expect(supportsAdaptiveThinking("claude-fable-5")).toBe(true) + }) + + it("is false for models that reject adaptive thinking (Haiku 4.5, older)", () => { + expect(supportsAdaptiveThinking("claude-haiku-4-5")).toBe(false) + expect(supportsAdaptiveThinking("claude-sonnet-4-5")).toBe(false) + }) +}) diff --git a/packages/agents/src/prompt.ts b/packages/agents/src/prompt.ts index cb2863b..133e741 100644 --- a/packages/agents/src/prompt.ts +++ b/packages/agents/src/prompt.ts @@ -3,6 +3,22 @@ import type { WorkerInput } from "@eng/core" /** Default model for agent runs. Opus 4.8 supports adaptive thinking. */ export const DEFAULT_MODEL = "claude-opus-4-8" +/** Models that accept `thinking: {type:"adaptive"}`. Others (Haiku 4.5, Sonnet 4.5, older) reject it + * with a 400, so the API backend must omit the param for them. */ +const ADAPTIVE_THINKING_MODELS = new Set([ + "claude-opus-4-8", + "claude-opus-4-7", + "claude-opus-4-6", + "claude-sonnet-4-6", + "claude-fable-5", + "claude-mythos-5", +]) + +/** Whether `model` supports adaptive thinking (so the API backend should send the `thinking` param). */ +export function supportsAdaptiveThinking(model: string): boolean { + return ADAPTIVE_THINKING_MODELS.has(model) +} + /** * Compose the session system prompt: the role persona plus the mission→goal→epic→ticket context, * so every agent always has the *why* (invariant #1). From ec94fb543cd5f68120cc6ad1a3f11d930cbaa7ac Mon Sep 17 00:00:00 2001 From: Toby V Date: Sat, 27 Jun 2026 15:57:55 +1000 Subject: [PATCH 2/2] fix(api): stream + raise output cap so large agent outputs aren't truncated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A whole-component implementation easily exceeds 16K output tokens. The API backend used a non-streaming messages.create capped at 16K, so the model's JSON proposal was silently cut off mid-object and dropped downstream as "agent output could not be parsed" — no code committed, QA blocks every ticket. Surfaced live on the local URL-shortener run (Haiku): every implement step produced a ~48K-char proposal truncated at exactly the cap. - api backend: switch to messages.stream(...).finalMessage() and raise the cap to 32K (streaming avoids the request-timeout risk at high max_tokens). - detect stop_reason === "max_tokens" and return a new StoppedReason "truncated" so a cut-off output is never mistaken for a valid response. - activities: audit a truncated implement step as "agent output truncated (hit token cap)", distinct from a real parse failure or an empty change set. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/server/src/temporal/activities.ts | 11 +++++++---- packages/agents/src/backends/api.ts | 19 ++++++++++++++++--- packages/core/src/worker.ts | 8 +++++++- 3 files changed, 30 insertions(+), 8 deletions(-) diff --git a/apps/server/src/temporal/activities.ts b/apps/server/src/temporal/activities.ts index b47af8b..074b38d 100644 --- a/apps/server/src/temporal/activities.ts +++ b/apps/server/src/temporal/activities.ts @@ -383,12 +383,15 @@ export async function implementTicket( return null } if (!proposed || proposed.files.length === 0) { - // Distinguish a genuine empty change set from a parse failure (ENG-009). + // Distinguish a genuine empty change set from a parse failure, and an output that was cut off at + // the token cap (incomplete JSON) from one the model never produced as JSON at all (ENG-009). const reason = !proposed ? "agent runtime unavailable" - : proposed.parsed - ? "no file changes proposed" - : "agent output could not be parsed" + : proposed.stoppedReason === "truncated" + ? "agent output truncated (hit token cap)" + : proposed.parsed + ? "no file changes proposed" + : "agent output could not be parsed" await persistence.audit.append({ actor: "system", kind: "delivery_skipped", diff --git a/packages/agents/src/backends/api.ts b/packages/agents/src/backends/api.ts index 42f2afa..7f59a48 100644 --- a/packages/agents/src/backends/api.ts +++ b/packages/agents/src/backends/api.ts @@ -14,10 +14,13 @@ export class ApiBackend implements WorkerBackend { } async run(input: WorkerInput, signal?: AbortSignal): Promise { - const response = await this.client.messages.create( + // A whole-component implementation easily exceeds 16K output tokens; at 16K it was silently + // truncated mid-JSON and dropped as "could not be parsed". Allow more headroom and stream — the + // skill's guidance is to stream any request with high `max_tokens` to avoid request timeouts. + const stream = this.client.messages.stream( { model: this.model, - max_tokens: affordableMaxTokens(this.model, input.budgetCentsRemaining, 16000), + max_tokens: affordableMaxTokens(this.model, input.budgetCentsRemaining, 32000), // Adaptive thinking only where the model supports it — Haiku 4.5 / older 400 on it. ...(supportsAdaptiveThinking(this.model) ? { thinking: { type: "adaptive" as const } } @@ -36,17 +39,27 @@ export class ApiBackend implements WorkerBackend { }, { signal }, ) + const response = await stream.finalMessage() const summary = response.content .map((block) => (block.type === "text" ? block.text : "")) .join("") .trim() + // Hitting the output cap leaves any JSON incomplete — surface it as `truncated` so the caller can + // audit it honestly instead of treating the cut-off text as an unparseable agent failure. + const stoppedReason = + response.stop_reason === "refusal" + ? "blocked" + : response.stop_reason === "max_tokens" + ? "truncated" + : "completed" + return { summary: summary || `[${input.role}] (no text returned)`, toolCalls: [], costCents: costCentsFromUsage(this.model, response.usage), - stoppedReason: response.stop_reason === "refusal" ? "blocked" : "completed", + stoppedReason, } } } diff --git a/packages/core/src/worker.ts b/packages/core/src/worker.ts index bc30726..e0076c1 100644 --- a/packages/core/src/worker.ts +++ b/packages/core/src/worker.ts @@ -37,7 +37,13 @@ export interface ToolCallRecord { at: string } -export type StoppedReason = "completed" | "budget_exhausted" | "needs_approval" | "blocked" +export type StoppedReason = + | "completed" + | "budget_exhausted" + | "needs_approval" + | "blocked" + /** Output hit the token cap and was cut off — any JSON is incomplete (do not trust `summary`). */ + | "truncated" export interface WorkerResult { summary: string