UNPKG

agentfootprint

Version:

The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.

249 lines • 11.6 kB
/** * MockProvider — deterministic LLMProvider for tests + examples. * * Pattern: Adapter (GoF, Design Patterns ch. 4). * Role: Ports-and-Adapters outer ring (Cockburn, 2005) — implements the * LLMProvider port without calling out to a real LLM service. * Emits: N/A (adapters don't emit; recorders observe them). * * Two modes, same provider: * • DEFAULT (no latency options) — instant `complete()`, no streaming. * Use in unit tests; behavior unchanged from earlier revisions. * • REALISTIC (`thinkingMs` set) — randomised "thinking" latency before * the response, plus a `stream()` implementation that emits the * content word-by-word with `chunkDelayMs` between chunks. Lets * consumers SEE the run unfold (pauses, streaming tokens, tool * dispatch) instead of having every step finish in microseconds. * * Realistic mode is the right default for the playground / Lens demos: * a real OpenAI / Anthropic call takes 3–8 s, so the UX you build for * that timing is what matters. Use `MockProvider.realistic()` for the * common 3–8 s thinking + 30–80 ms per word streaming preset. */ import { isSaidByPerson } from '../../lib/saidByPerson.js'; import { sleep } from '../../lib/sleep.js'; import { abortErrorOf, throwIfAborted } from '../../lib/abort.js'; import { mockThinkingHandler } from '../../thinking/MockThinkingHandler.js'; /** * Which roles this wire carries inside `messages`. * * The mock never filters by role — every message it is handed counts toward * the reply and the token estimate — so it carries all three. Tests that * exercise messages-slot delivery on every role can use it as the permissive * end of the range, with a capability-less provider as the floor. */ const CARRIES_IN_MESSAGES = Object.freeze(['system', 'user', 'assistant']); export class MockProvider { name; carriesInMessages = CARRIES_IN_MESSAGES; /** * The mock carries a forced tool choice, so `strategy: 'tool-forced'` is * testable end-to-end with no key and no network: script a reply whose * `toolCalls` names the synthetic tool and the whole loop — forced request, * tool-shaped answer, validation, retry — runs deterministically. A mock * that refused the strategy would make the one path that most needs a * cheap rehearsal the one path you could only rehearse in production. */ carriesForcedToolChoice = true; /** * The mock "carries" a structured-output format for the same reason it * carries a forced choice: the scripted reply IS the constrained answer, so * `strategy: 'native'` can be rehearsed offline. Its manifest states the * format whenever the request carried one. */ carriesNativeOutputSchema = true; /** Every mock model is a known synthetic transport, never an opaque endpoint. */ supportsAutoNativeOutputSchema = () => true; /** Normalizes the scripted `rawThinking` a reply carries. */ thinkingHandler = mockThinkingHandler; reply; replies; repliesCursor = 0; respond; thinkingMs; chunkDelayMs; stopReason; usageOverride; constructor(options = {}) { this.name = options.name ?? 'mock'; this.reply = options.reply; this.replies = options.replies; this.respond = options.respond ?? ((req) => { // The person's words, not the last user-role line: the library serves its own late // lines (`lib/saidByPerson.ts` · `LIBRARY_AUTHORED_PREFIXES`) last on a request (G17). const said = [...req.messages].reverse().find((m) => isSaidByPerson(m)); return said ? `echo: ${said.content}` : ''; }); // `delayMs` is kept as an alias of `thinkingMs` for back-compat. this.thinkingMs = options.thinkingMs ?? options.delayMs ?? 0; this.chunkDelayMs = options.chunkDelayMs ?? 30; this.stopReason = options.stopReason ?? 'stop'; this.usageOverride = options.usage; } /** * Reset the scripted-replies cursor. Useful when reusing one * `MockProvider` instance across multiple test scenarios — each * scenario can `provider.resetReplies()` to start from `replies[0]` * again. No-op when `replies` was not supplied. */ resetReplies() { this.repliesCursor = 0; } /** * Convenience factory for the playground / Lens demo defaults: a * real-feel mock with 3–8 s of "thinking" before the response and * 30–80 ms per streamed word. Lets users observe pause/resume, * streaming, and tool dispatch happening live without hitting a * paid API. */ static realistic(options = {}) { return new MockProvider({ thinkingMs: [3000, 8000], chunkDelayMs: [30, 80], ...options, }); } async complete(req) { // The same law as every real adapter (lib/abort.ts): a cancelled call is // not answered — even with no thinking time to wait out. throwIfAborted(req.signal); await sleep(pickMs(this.thinkingMs), req.signal, abortErrorOf); return this.buildResponse(req); } /** * Streaming mode — emits the response content word-by-word so * consumers (Lens commentary, chat UIs) can render tokens as they * arrive. Tool calls land all at once on the final chunk because * that is how real providers (OpenAI, Anthropic) deliver them too. */ async *stream(req) { throwIfAborted(req.signal); await sleep(pickMs(this.thinkingMs), req.signal, abortErrorOf); const response = this.buildResponse(req); const tokens = tokenizeWords(response.content); let tokenIndex = 0; for (const tok of tokens) { yield { tokenIndex: tokenIndex++, content: tok, done: false }; // Inter-chunk delay AFTER yielding so the first token shows up // immediately once thinking is done — matches OpenAI's pattern. await sleep(pickMs(this.chunkDelayMs), req.signal, abortErrorOf); // A zero delay waits for nothing, so the abort is asked for here too: // a cancelled stream yields nothing more. throwIfAborted(req.signal); } // Final chunk carries the authoritative LLMResponse (toolCalls, // usage, stopReason). Agent uses this to drive the ReAct loop // without needing a separate `complete()` round-trip. Real // streaming providers (Anthropic SSE, OpenAI delta) deliver // tool_use deltas across chunks; we collapse to the terminal-chunk // shape here because it's the simplest contract that lets one // call serve both UI streaming AND ReAct decisioning. yield { tokenIndex, content: '', done: true, response }; } buildResponse(req) { const raw = this.consumeNextReply(req); const partial = typeof raw === 'string' ? { content: raw } : raw; const content = partial.content ?? ''; const toolCalls = partial.toolCalls ?? []; const inputChars = messagesChars(req.messages) + (req.systemPrompt?.length ?? 0); const outputChars = content.length; return { content, toolCalls, usage: partial.usage ?? { input: this.usageOverride?.input ?? Math.ceil(inputChars / 4), output: this.usageOverride?.output ?? Math.ceil(outputChars / 4), ...(this.usageOverride?.cacheRead !== undefined && { cacheRead: this.usageOverride.cacheRead, }), ...(this.usageOverride?.cacheWrite !== undefined && { cacheWrite: this.usageOverride.cacheWrite, }), }, stopReason: partial.stopReason ?? (toolCalls.length > 0 ? 'tool_use' : this.stopReason), // Mock has no wire, so its honest manifest is an ECHO of the request's // tools — the healthy adapter's shape (body mirrors IR), unless a test // scripted a divergent manifest to stand in for a drifting adapter. wireManifest: partial.wireManifest ?? { toolNames: (req.tools ?? []).map((t) => t.name), ...(req.outputFormat !== undefined && { outputFormat: req.outputFormat.type }), }, }; } /** * Resolve the next reply source for one `complete()` / `stream()` call. * Priority: `replies` (scripted, throws on exhaustion) → `reply` (single * fixed string) → `respond` (callback default). Replies are consumed * in order; the cursor is per-instance, not per-request. */ consumeNextReply(req) { if (this.replies !== undefined) { if (this.repliesCursor >= this.replies.length) { throw new Error(`MockProvider[${this.name}] exhausted: scripted ${this.replies.length} replies ` + `but received request #${this.repliesCursor + 1}. Add more entries to ` + `\`replies\` or bound the agent with \`maxIterations\`. ` + `Call \`provider.resetReplies()\` to rewind across test scenarios.`); } // Cursor was just bounds-checked above (`>= replies.length` throws). // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const next = this.replies[this.repliesCursor]; this.repliesCursor++; return next; } if (this.reply !== undefined) return this.reply; return this.respond(req); } } /** * Lowercase factory for `MockProvider` — matches the v1 `mock()` import * shape so docs and quick-starts stay copy-pasteable. Equivalent to * `new MockProvider(options)`. * * @example * import { Agent, defineTool } from 'agentfootprint' import { mock } from 'agentfootprint/providers'; * * const agent = Agent.create({ provider: mock({ reply: 'hello' }) }) * .tool(defineTool({ name: 'echo', ... })) * .build(); */ export function mock(options = {}) { return new MockProvider(options); } // ── Helpers ───────────────────────────────────────────────────────── /** Pick a delay in ms — fixed when given a number, uniformly random * when given a `[min, max]` tuple. */ function pickMs(spec) { if (typeof spec === 'number') return Math.max(0, spec); const [min, max] = spec; if (max <= min) return Math.max(0, min); return Math.floor(min + Math.random() * (max - min)); } /** Split content into streamable units that read like real tokens — * whitespace-preserving word chunks. Empty content yields zero chunks. */ function tokenizeWords(content) { if (content.length === 0) return []; // Split on whitespace, preserve the delimiter so concatenating the // chunks reconstructs the original string exactly. const out = []; for (const m of content.matchAll(/\S+\s*/g)) out.push(m[0]); // Edge case: leading whitespace before the first word. const firstNonSpace = content.search(/\S/); if (firstNonSpace > 0) out.unshift(content.slice(0, firstNonSpace)); return out; } function messagesChars(messages) { let n = 0; for (const m of messages) n += m.content.length; return n; } //# sourceMappingURL=MockProvider.js.map