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
JavaScript
/**
* 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