Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions apps/server/src/temporal/activities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
26 changes: 21 additions & 5 deletions packages/agents/src/backends/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -14,11 +14,17 @@ export class ApiBackend implements WorkerBackend {
}

async run(input: WorkerInput, signal?: AbortSignal): Promise<WorkerResult> {
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),
thinking: { type: "adaptive" },
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 } }
: {}),
system: buildSystemPrompt(input),
messages: [{ role: "user", content: input.task }],
// Structured outputs (ENG-009): constrain the model to schema-conforming JSON when a schema
Expand All @@ -33,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,
}
}
}
15 changes: 15 additions & 0 deletions packages/agents/src/prompt.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
16 changes: 16 additions & 0 deletions packages/agents/src/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
8 changes: 7 additions & 1 deletion packages/core/src/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading