diff options
| author | Adam Malczewski <[email protected]> | 2026-05-24 13:24:04 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-05-24 13:24:04 +0900 |
| commit | 399e1509b93b9f3c56142f94b8fb2c30c2dedb2f (patch) | |
| tree | d67f18f5cca91a66e3146cbd2f48920571768e23 /packages/core/src/agent | |
| parent | 997b00034435440d412f955e05e53f09bae83f9e (diff) | |
| download | dispatch-399e1509b93b9f3c56142f94b8fb2c30c2dedb2f.tar.gz dispatch-399e1509b93b9f3c56142f94b8fb2c30c2dedb2f.zip | |
fix: prompt caching, OpenCode Go MiniMax/Qwen support, Opus 4.7 thinking, SDK compat
- Implement Anthropic prompt caching: first system message + last 2 non-system messages get cache_control: ephemeral, mirroring OpenCode's applyCaching strategy. Move system prompt inline into messages array so providerOptions can attach.
- Add opencode-anthropic provider variant routing MiniMax/Qwen models through the /messages endpoint with x-api-key auth, distinct from the Claude OAuth flow's Bearer auth and Claude Code mimicry.
- Split isAnthropic into isClaudeOAuth (billing header, mcp_ tool prefix, thinking config) and usesAnthropicSDK (cache markers) so non-OAuth Anthropic-format gateways get the right treatment.
- Pin @ai-sdk/anthropic to ^1.2.12: v3 returns LanguageModelV3-spec models that ai v4's streamText rejects at runtime ('AI SDK 4 only supports models that implement specification version v1'). Drop unnecessary V1 casts.
- Restore Opus 4.7 extended thinking by rewriting the outgoing /messages body in the Claude OAuth fetch interceptor: inject thinking: { type: 'adaptive' } (v1 SDK can't emit it), strip temperature/top_p/top_k (Anthropic rejects them with thinking enabled). Gated on max_tokens > 4096 so effort=none still works.
- Bump MAX_STEPS from 10 to 50 to align with AI SDK's stepCountIs(20) default and reduce mid-task halts.
- Fix pre-existing typecheck errors in agent-manager.ts (entry/nextEntry narrowing), app.ts (agentModels body field), KeyUsage.svelte (m guards), and a TS2742 in provider.ts via explicit ModelFactory return type.
- buildFallbackSequence now always returns at least one entry so processMessage runs the agent loop even without keyId/modelId (fixes 4 broken agent-manager tests).
Diffstat (limited to 'packages/core/src/agent')
| -rw-r--r-- | packages/core/src/agent/agent.ts | 138 |
1 files changed, 104 insertions, 34 deletions
diff --git a/packages/core/src/agent/agent.ts b/packages/core/src/agent/agent.ts index 24de59d..3cd8a5b 100644 --- a/packages/core/src/agent/agent.ts +++ b/packages/core/src/agent/agent.ts @@ -1,6 +1,6 @@ import { realpathSync } from "node:fs"; import { dirname, isAbsolute, relative, resolve } from "node:path"; -import type { CoreMessage, LanguageModelV1 } from "ai"; +import type { CoreMessage, CoreSystemMessage } from "ai"; import { streamText } from "ai"; import { buildBillingHeaderValue, SYSTEM_IDENTITY } from "../credentials/claude.js"; import { createProvider, prefixToolName, unprefixToolName } from "../llm/provider.js"; @@ -16,7 +16,7 @@ import type { ToolResult, } from "../types/index.js"; -function toCoreMessages(messages: ChatMessage[], isAnthropic?: boolean): CoreMessage[] { +function toCoreMessages(messages: ChatMessage[], useToolPrefix?: boolean): CoreMessage[] { const result: CoreMessage[] = []; for (const msg of messages) { if (msg.role === "user") { @@ -27,12 +27,12 @@ function toCoreMessages(messages: ChatMessage[], isAnthropic?: boolean): CoreMes | { type: "tool-call"; toolCallId: string; toolName: string; args: Record<string, unknown> } > = [{ type: "text", text: msg.content }]; for (const tc of msg.toolCalls ?? []) { - const toolName = isAnthropic ? prefixToolName(tc.name) : tc.name; + const toolName = useToolPrefix ? prefixToolName(tc.name) : tc.name; parts.push({ type: "tool-call", toolCallId: tc.id, toolName, args: tc.arguments }); } result.push({ role: "assistant", content: parts }); for (const tr of msg.toolResults ?? []) { - const toolName = isAnthropic ? prefixToolName(tr.toolName) : tr.toolName; + const toolName = useToolPrefix ? prefixToolName(tr.toolName) : tr.toolName; result.push({ role: "tool", content: [ @@ -45,6 +45,47 @@ function toCoreMessages(messages: ChatMessage[], isAnthropic?: boolean): CoreMes return result; } +/** + * Apply Anthropic prompt-caching breakpoints to a message list. + * + * Anthropic caches the entire request prefix up to (and including) any block + * marked with `cache_control`. Up to 4 breakpoints per request; we use three + * (first system + last 2 non-system). + * + * Strategy (mirrors OpenCode's `applyCaching` in transform.ts): + * - Mark the first system message → caches system prompt (and tools, which + * sit before messages in the request body). + * - Mark the last 2 non-system messages → rolling cache that extends through + * the conversation each turn. + * + * Only applied for the Anthropic provider. OpenCode Zen's OpenAI-compatible + * endpoint (`/zen/v1/chat/completions`) backs models like MiniMax, GLM, Kimi, + * Grok, etc. — those upstreams do automatic prefix caching server-side and + * don't accept `cache_control` markers. OpenCode's own transform.ts gates + * `applyCaching` on Anthropic-family detection for the same reason. Models + * served via `@ai-sdk/openai` (GPT) and `@ai-sdk/google` (Gemini) likewise + * use server-side automatic caching. + */ +function applyAnthropicCaching(msgs: CoreMessage[]): void { + const targets = new Set<CoreMessage>(); + + const systemMsgs = msgs.filter((m) => m.role === "system").slice(0, 2); + for (const m of systemMsgs) targets.add(m); + + const nonSystem = msgs.filter((m) => m.role !== "system").slice(-2); + for (const m of nonSystem) targets.add(m); + + for (const msg of targets) { + msg.providerOptions = { + ...msg.providerOptions, + anthropic: { + ...(msg.providerOptions?.anthropic ?? {}), + cacheControl: { type: "ephemeral" }, + }, + }; + } +} + function formatError(err: unknown, config: AgentConfig): string { const context = `[model=${config.model}, baseURL=${config.baseURL}]`; @@ -66,7 +107,7 @@ function formatError(err: unknown, config: AgentConfig): string { return `${String(err)} ${context}`; } -const MAX_STEPS = 10; +const MAX_STEPS = 50; export class Agent { status: AgentStatus = "idle"; @@ -223,7 +264,15 @@ export class Agent { this.messages.push({ role: "user", content: userMessage }); const registry = createToolRegistry(this.config.tools); - const isAnthropic = this.config.provider === "anthropic"; + // `isClaudeOAuth` gates Claude-Code-CLI-specific behavior: billing-header + // injection, identity preamble, `mcp_*` tool name prefix, and extended + // thinking config. Only the OAuth flow (provider="anthropic") needs these. + // `usesAnthropicSDK` is the broader category — any provider whose + // requests are serialized by `@ai-sdk/anthropic` and therefore expect + // Anthropic-style `cache_control` markers. Today that's Claude OAuth + // plus OpenCode Go's MiniMax/Qwen routes. + const isClaudeOAuth = this.config.provider === "anthropic"; + const usesAnthropicSDK = isClaudeOAuth || this.config.provider === "opencode-anthropic"; const providerFactory = createProvider({ apiKey: this.config.apiKey, baseURL: this.config.baseURL, @@ -231,17 +280,21 @@ export class Agent { claudeCredentials: this.config.claudeCredentials, }); - // For Anthropic provider, prefix tool names and build full system prompt + // Only the Claude OAuth flow expects `mcp_*` prefixed tool names. The + // OpenCode Go anthropic-format endpoint passes tools through to MiniMax + // or Qwen, which expect raw names. const aiTools = registry.getAISDKTools(); - const tools = isAnthropic + const tools = isClaudeOAuth ? Object.fromEntries( Object.entries(aiTools).map(([name, tool]) => [prefixToolName(name), tool]), ) : aiTools; - // Build system prompt + // Build system prompt — Claude OAuth requests embed a billing header + // and the Claude Code identity preamble so Anthropic recognizes the + // request as coming from the official CLI. let systemPrompt = this.config.systemPrompt; - if (isAnthropic) { + if (isClaudeOAuth) { const billingHeader = buildBillingHeaderValue(this.messages); systemPrompt = `${billingHeader}\n${SYSTEM_IDENTITY}\n\n${systemPrompt}`; } @@ -260,41 +313,58 @@ export class Agent { const effort = options?.reasoningEffort ?? this.config.reasoningEffort ?? "max"; // Build stream text options - const rawModel = providerFactory(this.config.model); - const model = rawModel as unknown as LanguageModelV1; + const model = providerFactory(this.config.model); + + // Build the message list with the system prompt prepended as a system + // role message. This is required for Anthropic prompt caching: the + // `system` shortcut parameter takes a plain string with nowhere to + // attach `providerOptions.anthropic.cacheControl`. Moving it inline + // also lets us apply rolling cache breakpoints to the last messages. + const systemMessage: CoreSystemMessage = { role: "system", content: systemPrompt }; + const coreMessages: CoreMessage[] = [ + systemMessage, + ...toCoreMessages(stepMessages, isClaudeOAuth), + ]; + + if (usesAnthropicSDK) { + applyAnthropicCaching(coreMessages); + } + const streamOptions: Parameters<typeof streamText>[0] = { model, - system: systemPrompt, - messages: toCoreMessages(stepMessages, isAnthropic), + messages: coreMessages, tools, }; - if (isAnthropic && effort !== "none") { - const modelId = this.config.model; - const isOpus47 = modelId === "claude-opus-4-7"; - + if (isClaudeOAuth && effort !== "none") { + // Opus 4.7 rejects `thinking: { type: "enabled" }` ("reasoning- + // signature without reasoning") and only supports adaptive thinking. + // `@ai-sdk/anthropic` v1.x can't emit `type: "adaptive"`, so we + // leave `providerOptions.anthropic.thinking` unset and let the + // custom fetch in `createClaudeOAuthProvider` inject the adaptive + // shape into the request body. We still set `maxTokens` here so + // the SDK serializes it — adaptive thinking spends from this + // budget rather than a separate one. + const isOpus47 = this.config.model === "claude-opus-4-7"; + const budgetTokens = + effort === "max" + ? 16000 + : effort === "high" + ? 10000 + : effort === "medium" + ? 5000 + : effort === "low" + ? 2000 + : 0; if (isOpus47) { - // Opus 4.7 only supports adaptive thinking - streamOptions.providerOptions = { - anthropic: { thinking: { type: "adaptive" as const } }, - }; + streamOptions.maxTokens = budgetTokens + 8000; } else { - const budgetTokens = - effort === "max" - ? 16000 - : effort === "high" - ? 10000 - : effort === "medium" - ? 5000 - : effort === "low" - ? 2000 - : 0; streamOptions.providerOptions = { anthropic: { thinking: { type: "enabled" as const, budgetTokens } }, }; streamOptions.maxTokens = budgetTokens + 8000; } - } else if (!isAnthropic && effort !== "none") { + } else if (!usesAnthropicSDK && effort !== "none") { streamOptions.providerOptions = { openaiCompatible: { reasoningEffort: effort } }; } @@ -313,7 +383,7 @@ export class Agent { yield { type: "reasoning-delta", delta: event.textDelta }; } else if (event.type === "tool-call") { const rawName = event.toolName; - const toolName = isAnthropic ? unprefixToolName(rawName) : rawName; + const toolName = isClaudeOAuth ? unprefixToolName(rawName) : rawName; const toolCall: ToolCall = { id: event.toolCallId, name: toolName, |
