agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
270 lines • 13.6 kB
JavaScript
/**
* AnthropicProvider — wraps `@anthropic-ai/sdk` as an `LLMProvider`.
*
* Pattern: Adapter (GoF) + Ports-and-Adapters (Cockburn 2005).
* Role: Outer ring — translates `LLMRequest`/`LLMResponse` to/from
* Anthropic's Messages API. Knows nothing about agents,
* recorders, or compositions.
* Emits: N/A — providers don't emit; recorders observe via Agent.
*
* ─── Limitations ────────────────────────────────────────────────────
*
* • Multi-modal content (images, video) NOT supported — the framework's
* `LLMMessage.content` is `string`. The adapter accepts text-only.
* May extend in a future release the message shape; this provider will be updated
* in lockstep.
* • `responseFormat` (JSON-Schema-coerced output) NOT exposed
* — consumers can pass schema instructions via `systemPrompt`.
*/
import { lazyRequire } from '../../lib/lazyRequire.js';
import { abortableIterable, abortErrorOf, throwIfAborted, untilAborted } from '../../lib/abort.js';
import { asContextWindowExceeded } from '../../provider-contract/contextWindow.js';
import { retryAfterMsFromError } from './retryAfter.js';
import { ANTHROPIC_PROMPT_CACHING } from './anthropicCacheWire.js';
import { thinkingModeWith } from './anthropicThinkingWire.js';
import { anthropicModelFacts, anthropicTakesForcedToolChoice, anthropicTakesNativeOutputSchema, } from './anthropicModels.js';
import { prepareAnthropicReplay, thinkingReplayBinding } from './anthropicThinkingReplay.js';
import { toolManifestOf } from './wireManifest.js';
import { AnthropicToolArguments } from './anthropicToolArguments.js';
import { anthropicThinkingHandler } from '../../thinking/AnthropicThinkingHandler.js';
// The request body has ONE owner, shared with browserAnthropic() and
// invokeModelGateway() — a private copy here once drifted from it, and a
// second copy of the thinking rule would drift the same way.
import { buildMessagesBody, fromAnthropicResponse, } from './anthropicMessagesWire.js';
/**
* Which roles this wire carries inside `messages`.
*
* `'system'` is ABSENT and that is the wire's own rule, not a policy choice:
* `toAnthropicMessages` drops a `role: 'system'` message because Anthropic
* takes the system prompt as a separate top-level field. Declaring the truth
* here is what lets a `slot: 'messages'` injection be refused at run start
* rather than silently vanish between the recording and the request.
*/
const CARRIES_IN_MESSAGES = Object.freeze(['user', 'assistant']);
/**
* Build an `LLMProvider` backed by Anthropic's Messages API.
*
* @example
* import { Agent } from 'agentfootprint';
* import { anthropic } from 'agentfootprint/providers';
*
* const agent = Agent.create({
* provider: anthropic({ defaultModel: 'claude-sonnet-4-5-20250929' }),
* model: 'anthropic',
* })
* .tool(weatherTool)
* .build();
*/
export function anthropic(options = {}) {
const client = resolveClient(options);
const defaultModel = options.defaultModel ?? 'claude-sonnet-4-5-20250929';
const defaultMaxTokens = options.defaultMaxTokens ?? 4096;
const parallelToolCalls = options.parallelToolCalls;
/** The model a request goes to — the `'anthropic'` shorthand is the default. */
const modelOf = (model) => (model === 'anthropic' ? defaultModel : model);
/** The ONE mode function: declared below and used for every body. */
const modeOf = thinkingModeWith(options.thinkingMode);
const buildParams = (req) => {
const model = modelOf(req.model);
return {
model,
...buildMessagesBody(req, {
model,
thinkingMode: modeOf,
provider: 'anthropic',
maxTokensDefault: defaultMaxTokens,
...(parallelToolCalls !== undefined && { parallelToolCalls }),
nativeOutputSchema: anthropicTakesNativeOutputSchema,
}),
};
};
const provider = {
name: 'anthropic',
carriesInMessages: CARRIES_IN_MESSAGES,
carriesForcedToolChoice: true,
supportsForcedToolChoice: (model) => anthropicTakesForcedToolChoice(modelOf(model)),
// `output_config.format` — the `'native'` output strategy — on the models
// the structured-outputs page lists (anthropicModels.ts).
carriesNativeOutputSchema: true,
supportsNativeOutputSchema: (model) => anthropicTakesNativeOutputSchema(modelOf(model)),
supportsAutoNativeOutputSchema: (model) => anthropicModelFacts(modelOf(model))?.nativeOutputSchema === true,
// Explicit `cache_control` breakpoints, four per request, usage reported —
// the agent's cache strategy is chosen from this, never from `name`.
promptCaching: ANTHROPIC_PROMPT_CACHING,
// The signed thinking blocks this wire returns, normalized for the echo.
thinkingHandler: anthropicThinkingHandler,
// Which thinking request each model takes — budget, adaptive or none
// (anthropicThinkingWire.ts); `buildMessagesBody` sends that shape, from
// the same function.
thinkingMode: (model) => modeOf(modelOf(model)),
async complete(req, hooks) {
// A cancelled call sends nothing (lib/abort.ts, law 1).
throwIfAborted(req.signal);
const preparation = prepareAnthropicReplay(req, modelOf(req.model), 'anthropic');
const params = buildParams(preparation.request);
const bindingFactory = thinkingReplayBinding(params, params.model);
if (preparation.omissions.length > 0)
hooks?.onReplayPrepared?.(preparation);
try {
// The signal rides the SDK's request options, so an abort cancels the
// HTTP request itself; the race refuses an answer that lands after it.
const message = await untilAborted(req.signal
? client.messages.create(params, { signal: req.signal })
: client.messages.create(params), req.signal);
// Manifest read from the FINAL params — after tool mapping and cache
// markers — because the whole point is catching what the body says,
// not what the request intended (wireManifest.ts).
return {
...fromAnthropicResponse(message, message.id ? bindingFactory(message.id) : undefined),
wireManifest: toolManifestOf(params.tools, params.output_config),
...(preparation.omissions.length > 0 && { replayOmissions: preparation.omissions }),
};
}
catch (err) {
throw wrapError(err, req.signal);
}
},
async *stream(req, hooks) {
throwIfAborted(req.signal);
const preparation = prepareAnthropicReplay(req, modelOf(req.model), 'anthropic');
const params = buildParams(preparation.request);
const bindingFactory = thinkingReplayBinding(params, params.model);
if (preparation.omissions.length > 0)
hooks?.onReplayPrepared?.(preparation);
let stream;
try {
stream = req.signal
? client.messages.stream(params, { signal: req.signal })
: client.messages.stream(params);
}
catch (err) {
throw wrapError(err, req.signal);
}
const opened = stream;
let tokenIndex = 0;
const toolArguments = new AnthropicToolArguments();
try {
// An abort closes the SDK stream and ends this loop at once, even
// when a read is still pending (lib/abort.ts, laws 2–3).
for await (const event of abortableIterable(opened, req.signal, () => opened.abort?.())) {
toolArguments.observe(event);
if (event.type === 'content_block_delta' &&
event.delta?.type === 'text_delta' &&
event.delta.text) {
yield { tokenIndex, content: event.delta.text, done: false };
tokenIndex++;
}
}
const final = await untilAborted(opened.finalMessage(), req.signal);
toolArguments.assertTerminal(final.content.some((block) => block.type === 'tool_use'));
const argumentFailures = new Map();
const checkedMessage = {
...final,
content: final.content.map((block, index) => {
if (block.type !== 'tool_use')
return block;
const checked = toolArguments.finish(index, block);
if (checked.failure)
argumentFailures.set(index, checked.failure);
return { ...block, input: checked.input };
}),
};
const response = {
...fromAnthropicResponse(checkedMessage, final.id ? bindingFactory(final.id) : undefined, argumentFailures),
wireManifest: toolManifestOf(params.tools, params.output_config),
...(preparation.omissions.length > 0 && { replayOmissions: preparation.omissions }),
};
yield { tokenIndex, content: '', done: true, response };
}
catch (err) {
throw wrapError(err, req.signal);
}
},
};
return provider;
}
/**
* Class form for consumers who prefer `new AnthropicProvider(...)` over
* the `anthropic(...)` factory. Identical behavior; trivial wrapper.
*/
export class AnthropicProvider {
name = 'anthropic';
carriesInMessages = CARRIES_IN_MESSAGES;
carriesForcedToolChoice = true;
/** The inner declaration resolves this adapter's shorthand before looking up the model. */
supportsForcedToolChoice;
carriesNativeOutputSchema = true;
/** The inner declaration resolves this adapter's shorthand before looking up the model. */
supportsNativeOutputSchema;
supportsAutoNativeOutputSchema;
promptCaching = ANTHROPIC_PROMPT_CACHING;
thinkingHandler = anthropicThinkingHandler;
/** The inner provider's own function — a closure, safe to forward unbound. */
thinkingMode;
inner;
constructor(options = {}) {
this.inner = anthropic(options);
this.thinkingMode = this.inner.thinkingMode;
this.supportsForcedToolChoice = this.inner.supportsForcedToolChoice;
this.supportsNativeOutputSchema = this.inner.supportsNativeOutputSchema;
this.supportsAutoNativeOutputSchema =
this.inner.supportsAutoNativeOutputSchema ?? (() => false);
}
// `hooks` is FORWARDED, not dropped — see LLMCallHooks in provider-contract/types.ts.
complete(req, hooks) {
return this.inner.complete(req, hooks);
}
stream(req, hooks) {
if (!this.inner.stream) {
throw new Error('stream() unavailable on inner provider');
}
return this.inner.stream(req, hooks);
}
}
// ─── Internals ──────────────────────────────────────────────────────
function resolveClient(options) {
if (options._client)
return options._client;
let Anthropic;
try {
const mod = lazyRequire('@anthropic-ai/sdk');
Anthropic = (mod.default ?? mod);
}
catch {
throw new Error('AnthropicProvider requires @anthropic-ai/sdk.\n' +
' Install: npm install @anthropic-ai/sdk\n' +
' Or pass `_client` for test injection.');
}
const apiKey = options.apiKey ?? process.env.ANTHROPIC_API_KEY;
return new Anthropic({
apiKey,
...(options.timeout !== undefined ? { timeout: options.timeout } : {}),
...(options.maxRetries !== undefined ? { maxRetries: options.maxRetries } : {}),
});
}
function wrapError(err, signal) {
// A cancelled call fails AS the cancellation, whatever the SDK threw for it
// (its own `APIUserAbortError` included) — never as a provider error that a
// retry would re-send (lib/abort.ts).
if (signal?.aborted)
return abortErrorOf(signal);
// See OpenAIProvider.wrapError — one shared detector, one typed error, so
// "prompt is too long" reads the same whichever vendor said it.
const tooBig = asContextWindowExceeded(err, { provider: 'anthropic' });
if (tooBig)
return tooBig;
if (err instanceof Error) {
// The wait the response stated (retry-after-ms / retry-after), declared
// for withRetry — absent when none, so the error shape is unchanged.
const retryAfterMs = retryAfterMsFromError(err);
return Object.assign(new Error(`[anthropic] ${err.message}`), {
name: 'AnthropicProviderError',
cause: err,
// Preserve `status` if the SDK attached one — withRetry uses it.
status: err.status,
...(retryAfterMs !== undefined && { retryAfterMs }),
});
}
return new Error(`[anthropic] ${String(err)}`);
}
//# sourceMappingURL=AnthropicProvider.js.map