diff --git a/packages/js/src/budget.ts b/packages/js/src/budget.ts new file mode 100644 index 0000000..f85cef2 --- /dev/null +++ b/packages/js/src/budget.ts @@ -0,0 +1,29 @@ +import type { Usage } from './types.js'; + +/** + * Budget caps a run so it stops deterministically (invariant §2.13) — a runaway + * loop is a bug, not an edge case. M7.3 mirrors the Python and Go caps (turns + + * tokens); cost and time join with the rest of the token engine. + */ +export interface Budget { + /** + * Bounds the number of model calls, INCLUDING the final-answer turn: a goal + * needing k rounds of tool calls plus an answer needs `max_turns >= k + 1`. + */ + max_turns: number; + /** Bounds total token spend (undefined/0 = uncapped). */ + max_tokens?: number; +} + +/** The out-of-the-box cap (8 turns, tokens uncapped). */ +export function defaultBudget(): Budget { + return { max_turns: 8 }; +} + +/** Whether cumulative usage has exceeded the token cap. */ +export function overTokens(budget: Budget, usage: Usage): boolean { + if (!budget.max_tokens) { + return false; + } + return usage.input_tokens + usage.output_tokens > budget.max_tokens; +} diff --git a/packages/js/src/index.ts b/packages/js/src/index.ts index cf9d2d6..b8dac9d 100644 --- a/packages/js/src/index.ts +++ b/packages/js/src/index.ts @@ -14,3 +14,7 @@ export * from './model.js'; export * from './classify.js'; export * from './linter.js'; export * from './capability.js'; +export * from './registry.js'; +export * from './budget.js'; +export * from './stop.js'; +export * from './loop.js'; diff --git a/packages/js/src/loop.ts b/packages/js/src/loop.ts new file mode 100644 index 0000000..3b9817e --- /dev/null +++ b/packages/js/src/loop.ts @@ -0,0 +1,305 @@ +import { randomBytes } from 'node:crypto'; +import type { + CapabilityResult, + Context, + FinishReason, + Message, + ModelResponse, + RunResult, + RunState, + Usage, +} from './types.js'; +import type { Model } from './model.js'; +import type { BoundCapability } from './capability.js'; +import { CapabilityRegistry } from './registry.js'; +import { defaultModelParams } from './types.js'; +import { type Budget, defaultBudget, overTokens } from './budget.js'; +import { GoalReached, MaxTurns, type StopCondition, firstStop } from './stop.js'; + +/** + * The harness loop and the Agent — the TypeScript mirror of the ETCSLV lifecycle: + * Engage → (Think → Call → Sense → Loop?) → Verify. The model is the only + * stochastic step; dispatch and stopping are deterministic and owned here + * (invariant §2.14). + * + * ask() is structurally read-only — a requested write is refused before anything + * executes (§2.1). run() may write, but the policy → approval → audit gate arrives + * in M7.4; until then a write under run() fails closed (denied, never executed). + * The caller's principal propagates as a Context into every call (§2.7). + */ + +const SYSTEM_PROMPT = + 'You operate an application through its registered capabilities. ' + + "Use them to accomplish the user's goal, then reply with a final answer."; + +function newTraceId(): string { + return randomBytes(16).toString('hex'); +} + +/** The outcome of a run: `completed` | `refused` | `not_executed` | `stopped`. */ +export type Outcome = 'completed' | 'refused' | 'not_executed' | 'stopped'; + +/** The detailed result of one run: outcome plus telemetry. */ +export interface RunReport { + outcome: Outcome; + output: Message; + finish: FinishReason; + usage: Usage; + turn: number; + executed: string[]; + reason: string; + trace_id: string; +} + +/** Per-run governance. M7.3 wires identity and the budget; safety options arrive in M7.4. */ +export interface RunOptions { + principal?: string; + budget?: Budget; +} + +/** + * Drive the loop. A control-plane failure (the model rejecting) propagates as a + * thrown error; everything the model does wrong is data. + */ +export async function runLoop( + model: Model, + registry: CapabilityRegistry, + goal: string, + canWrite: boolean, + options: RunOptions = {}, +): Promise { + const budget = options.budget ?? defaultBudget(); + const maxTurns = budget.max_turns || defaultBudget().max_turns; + const traceId = newTraceId(); + const ctx: Context = { principal: options.principal, trace_id: traceId }; + + const messages: Message[] = [ + { role: 'system', text: SYSTEM_PROMPT }, + { role: 'user', text: goal }, + ]; + const stops: StopCondition[] = [new GoalReached(), new MaxTurns(maxTurns)]; + const executed: string[] = []; + const params = defaultModelParams(); + + let usage: Usage = { input_tokens: 0, output_tokens: 0, cost: 0 }; + let last: ModelResponse | undefined; + let turn = 0; + + for (;;) { + if (overTokens(budget, usage)) { + return report( + 'stopped', + synthetic('Stopped: token budget exhausted.\n → raise max_tokens or simplify the goal.'), + 'interrupted', + usage, + turn, + executed, + 'budget_exhausted', + traceId, + ); + } + + const resp = await model.complete(messages, registry.toolSpecs(), params); + usage = addUsage(usage, resp.usage); + messages.push(resp.message); + last = resp; + + if (resp.finish_reason === 'tool_calls' && (resp.message.tool_calls?.length ?? 0) > 0) { + const blocked = await act( + registry, + resp.message.tool_calls!, + canWrite, + ctx, + messages, + executed, + usage, + turn, + traceId, + ); + if (blocked) { + return blocked; + } + } + + turn += 1; + const state: RunState = { + messages, + turn, + cumulative_usage: usage, + last_response: last, + }; + const stop = firstStop(stops, state); + if (stop.stop) { + if (stop.reason === 'goal_reached') { + return report('completed', resp.message, 'stop', usage, turn, executed, '', traceId); + } + return report( + 'stopped', + synthetic('Stopped: turn budget exhausted.\n → raise the budget or simplify the goal.'), + 'interrupted', + usage, + turn, + executed, + 'budget_exhausted', + traceId, + ); + } + } +} + +/** + * Execute one turn's tool calls. Returns a terminal report if the turn is refused + * (ask + write) or denied (run + write, until M7.4), otherwise mutates `messages` + * and `executed` and returns undefined so the loop continues. + */ +async function act( + registry: CapabilityRegistry, + toolCalls: NonNullable, + canWrite: boolean, + ctx: Context, + messages: Message[], + executed: string[], + usage: Usage, + turn: number, + traceId: string, +): Promise { + const items = toolCalls.map((call) => ({ call, cap: registry.get(call.name) })); + + for (const { call, cap } of items) { + if (cap && cap.spec.access !== 'read') { + if (!canWrite) { + // ask(): structurally read-only — refuse the whole turn (§2.1). + return report( + 'refused', + synthetic( + `Refused: "${call.name}" would write, but this is a read-only ask().\n → use run(...) if writing is intended.`, + ), + 'stop', + usage, + turn, + executed, + 'write_in_read_only', + traceId, + ); + } + // run(): the policy/approval gate arrives in M7.4; until then writes fail closed. + return report( + 'not_executed', + synthetic( + `Not executed: "${call.name}" is a write, which needs an approval policy.\n → configure an autonomy level and approval handler (M7.4).`, + ), + 'stop', + usage, + turn, + executed, + 'write_denied', + traceId, + ); + } + } + + for (const { call, cap } of items) { + const result = await registry.call(call.name, call.arguments ?? {}, ctx); + if (cap) { + executed.push(call.name); + } + messages.push(toolMessage(call.id, result)); + } + return undefined; +} + +function toolMessage(callId: string, result: CapabilityResult): Message { + const text = result.ok ? stringifyValue(result.value) : (result.error ?? ''); + return { role: 'tool', tool_call_id: callId, text }; +} + +function stringifyValue(value: unknown): string { + if (value === null || value === undefined) { + return ''; + } + return typeof value === 'string' ? value : JSON.stringify(value); +} + +function synthetic(text: string): Message { + return { role: 'assistant', text }; +} + +function addUsage(a: Usage, b: Usage): Usage { + return { + input_tokens: a.input_tokens + b.input_tokens, + output_tokens: a.output_tokens + b.output_tokens, + cost: a.cost + b.cost, + }; +} + +function report( + outcome: Outcome, + output: Message, + finish: FinishReason, + usage: Usage, + turn: number, + executed: string[], + reason: string, + traceId: string, +): RunReport { + return { + outcome, + output, + finish, + usage, + turn, + executed: [...executed], + reason, + trace_id: traceId, + }; +} + +// --- the Agent -------------------------------------------------------------------- + +/** Configuration for an {@link Agent}. Safety options (autonomy, approval, audit) arrive in M7.4. */ +export interface AgentConfig { + capabilities?: BoundCapability[]; + budget?: Budget; + /** Who the agent acts for — propagated into every capability call (identity → RLS). */ + principal?: string; +} + +/** + * The user-facing harness: give it your capabilities, hand it a goal. `ask()` is + * read-only and never prompts; `run()` may write (gated by the autonomy ladder from + * M7.4 onward). A duplicate capability name is a control-plane error (thrown). + */ +export class Agent { + readonly registry: CapabilityRegistry; + private readonly model: Model; + private readonly budget: Budget; + private readonly principal: string | undefined; + + constructor(model: Model, config: AgentConfig = {}) { + this.model = model; + this.budget = config.budget ?? defaultBudget(); + this.principal = config.principal; + this.registry = new CapabilityRegistry(); + for (const cap of config.capabilities ?? []) { + this.registry.register(cap); + } + } + + /** Accomplish a goal using only read capabilities (structurally read-only). */ + ask(goal: string): Promise { + return this.drive(goal, false); + } + + /** Accomplish a goal that may write; writes are gated by the autonomy ladder (M7.4). */ + run(goal: string): Promise { + return this.drive(goal, true); + } + + private async drive(goal: string, canWrite: boolean): Promise { + const rep = await runLoop(this.model, this.registry, goal, canWrite, { + principal: this.principal, + budget: this.budget, + }); + return { output: rep.output, reason: rep.finish, usage: rep.usage, trace_id: rep.trace_id }; + } +} diff --git a/packages/js/src/registry.ts b/packages/js/src/registry.ts new file mode 100644 index 0000000..a7bfb53 --- /dev/null +++ b/packages/js/src/registry.ts @@ -0,0 +1,188 @@ +import type { BoundCapability } from './capability.js'; +import type { Capability, CapabilityResult, Context, JSONSchema, ToolSpec } from './types.js'; +import { resultOk, resultError, toolSpec } from './types.js'; +import { ReinsError } from './errors.js'; + +/** + * CapabilityRegistry is the only path by which the agent reaches a capability. It + * validates arguments against the schema BEFORE executing (model output is + * untrusted — the schema is the prepared statement, invariant §2.6), then invokes + * the handler and wraps the outcome as errors-as-data ({@link CapabilityResult}). + * Only control-plane misuse (duplicate registration) throws. + */ +export class CapabilityRegistry { + private readonly caps = new Map(); + private readonly order: string[] = []; + + /** Register a capability; a duplicate name is a control-plane error. */ + register(cap: BoundCapability): void { + if (this.caps.has(cap.spec.name)) { + throw new ReinsError( + `capability "${cap.spec.name}" is already registered`, + 'give each capability a unique name', + ); + } + this.caps.set(cap.spec.name, cap); + this.order.push(cap.spec.name); + } + + /** The capability specs in registration order. */ + list(): Capability[] { + return this.order.map((name) => this.caps.get(name)!.spec); + } + + /** The intent surfaces shown to the model (never policy metadata, §2.5). */ + toolSpecs(): ToolSpec[] { + return this.order.map((name) => toolSpec(this.caps.get(name)!.spec)); + } + + /** The bound capability, or undefined if it is not registered. */ + get(name: string): BoundCapability | undefined { + return this.caps.get(name); + } + + /** Check args against the capability's schema; throws a hinted error if invalid. */ + validate(name: string, args: Record): void { + const cap = this.caps.get(name); + if (!cap) { + throw new ReinsError(`no capability named "${name}"`, `available: ${this.order.join(', ')}`); + } + const err = validateArgs(cap.spec.input_schema, args, name); + if (err) { + throw err; + } + } + + /** + * Validate then invoke, returning the outcome as data (never throwing over the + * loop boundary). The run's {@link Context} is passed to the handler (identity, + * §2.7). + */ + async call(name: string, args: Record, ctx: Context): Promise { + const cap = this.caps.get(name); + if (!cap) { + const matches = closestMatches(name, this.order, 3); + const fix = matches.length + ? `closest matches: ${matches.join(', ')}` + : 'register it with a capability'; + return resultError(`no capability named "${name}"\n → ${fix}`); + } + const err = validateArgs(cap.spec.input_schema, args, name); + if (err) { + return resultError(err.message); + } + try { + const value = await cap.handler(ctx, args); + return resultOk(value); + } catch (e) { + return resultError(e instanceof Error ? e.message : String(e)); + } + } +} + +// --- schema validation (top-level keys + JSON types; fail-closed) ------------------ + +/** Returns a hinted error if args do not satisfy the schema, else null. */ +export function validateArgs( + schema: JSONSchema | undefined, + args: Record, + capName: string, +): ReinsError | null { + const properties = asObject(schema?.['properties']) ?? {}; + const allowExtra = schema?.['additionalProperties'] === true; + + for (const key of requiredKeys(schema)) { + if (!(key in args)) { + return new ReinsError(`${capName}: missing required argument "${key}"`, `include ${key}`); + } + } + for (const [key, value] of Object.entries(args)) { + const spec = properties[key]; + if (spec === undefined) { + if (allowExtra) { + continue; + } + return new ReinsError( + `${capName}: unexpected argument "${key}"`, + `allowed: ${Object.keys(properties).sort().join(', ')}`, + ); + } + const expected = asObject(spec)?.['type']; + if (typeof expected === 'string' && !typeMatches(expected, value)) { + return new ReinsError( + `${capName}: argument "${key}" must be of type ${expected}`, + `got ${describeType(value)}`, + ); + } + } + return null; +} + +function requiredKeys(schema: JSONSchema | undefined): string[] { + const req = schema?.['required']; + if (Array.isArray(req)) { + return req.filter((v): v is string => typeof v === 'string'); + } + return []; +} + +function typeMatches(expected: string, value: unknown): boolean { + switch (expected) { + case 'integer': + return typeof value === 'number' && Number.isInteger(value); + case 'number': + return typeof value === 'number'; + case 'boolean': + return typeof value === 'boolean'; + case 'string': + return typeof value === 'string'; + case 'array': + return Array.isArray(value); + case 'object': + return typeof value === 'object' && value !== null && !Array.isArray(value); + default: + return true; // unknown type — accept + } +} + +function asObject(value: unknown): Record | undefined { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function describeType(value: unknown): string { + if (value === null) return 'null'; + if (Array.isArray(value)) return 'array'; + return typeof value; +} + +// --- closest-match suggestions ---------------------------------------------------- + +/** + * Up to n candidates within a small edit distance of name, nearest first — the + * "did you mean" for an unknown capability (mirrors Python's difflib and the Go + * Levenshtein helper). + */ +export function closestMatches(name: string, candidates: string[], n: number): string[] { + const cutoff = Math.floor(name.length / 2) + 1; + return candidates + .map((c) => ({ name: c, dist: levenshtein(name, c) })) + .filter((s) => s.dist <= cutoff) + .sort((a, b) => a.dist - b.dist) + .slice(0, n) + .map((s) => s.name); +} + +function levenshtein(a: string, b: string): number { + let prev = Array.from({ length: b.length + 1 }, (_, j) => j); + for (let i = 1; i <= a.length; i++) { + const cur = [i]; + for (let j = 1; j <= b.length; j++) { + const cost = a[i - 1] === b[j - 1] ? 0 : 1; + cur[j] = Math.min(prev[j]! + 1, cur[j - 1]! + 1, prev[j - 1]! + cost); + } + prev = cur; + } + return prev[b.length]!; +} diff --git a/packages/js/src/stop.ts b/packages/js/src/stop.ts new file mode 100644 index 0000000..14dbdd7 --- /dev/null +++ b/packages/js/src/stop.ts @@ -0,0 +1,48 @@ +import type { RunState } from './types.js'; + +/** The outcome of evaluating a stop condition. */ +export interface StopVerdict { + reason: string; + stop: boolean; +} + +/** + * A StopCondition decides whether a run should stop, given its evolving state. The + * loop owns stopping deterministically; conditions are pure and testable. + */ +export interface StopCondition { + evaluate(state: RunState): StopVerdict; +} + +/** Stops when the model produced a final answer (finish reason `'stop'`). */ +export class GoalReached implements StopCondition { + evaluate(state: RunState): StopVerdict { + if (state.last_response?.finish_reason === 'stop') { + return { reason: 'goal_reached', stop: true }; + } + return { reason: '', stop: false }; + } +} + +/** Stops once the turn count reaches the cap. */ +export class MaxTurns implements StopCondition { + constructor(private readonly limit: number) {} + + evaluate(state: RunState): StopVerdict { + if (state.turn >= this.limit) { + return { reason: 'max_turns', stop: true }; + } + return { reason: '', stop: false }; + } +} + +/** The reason of the first condition that fires, or a non-stop verdict. */ +export function firstStop(conditions: StopCondition[], state: RunState): StopVerdict { + for (const c of conditions) { + const verdict = c.evaluate(state); + if (verdict.stop) { + return verdict; + } + } + return { reason: '', stop: false }; +} diff --git a/packages/js/tests/loop.test.ts b/packages/js/tests/loop.test.ts new file mode 100644 index 0000000..9917a48 --- /dev/null +++ b/packages/js/tests/loop.test.ts @@ -0,0 +1,148 @@ +import { describe, it, expect } from 'vitest'; +import { + Agent, + CapabilityRegistry, + FakeModel, + capability, + params, + runLoop, + callResponse, + finalResponse, + type Context, +} from '../src/index.js'; + +function readCap(onCall?: (ctx: Context) => void) { + return capability( + 'get_order', + { description: 'Get an order by its id.', parameters: params({ id: 'integer' }) }, + (ctx) => { + onCall?.(ctx); + return { id: 1, total: 42 }; + }, + ); +} + +let deleteCalls = 0; +function deleteCap() { + return capability( + 'delete_order', + { description: 'Delete an order by its id.', parameters: params({ id: 'integer' }) }, + () => { + deleteCalls += 1; + return { deleted: true }; + }, + ); +} + +describe('runLoop — reads', () => { + it('completes a read goal, calling the capability then answering', async () => { + const model = new FakeModel( + callResponse('c1', 'get_order', { id: 1 }), + finalResponse('The order total is 42.'), + ); + const reg = new CapabilityRegistry(); + reg.register(readCap()); + + const rep = await runLoop(model, reg, 'what is order 1 total?', false); + expect(rep.outcome).toBe('completed'); + expect(rep.executed).toEqual(['get_order']); + expect(rep.output.text).toBe('The order total is 42.'); + expect(rep.finish).toBe('stop'); + }); + + it('feeds an unknown capability back to the model as errors-as-data', async () => { + const model = new FakeModel( + callResponse('c1', 'get_ordr', {}), + finalResponse('Sorry, retried.'), + ); + const reg = new CapabilityRegistry(); + reg.register(readCap()); + + const rep = await runLoop(model, reg, 'try a typo', false); + expect(rep.outcome).toBe('completed'); + expect(rep.executed).toEqual([]); // the unknown call did not execute + // The tool result the model saw on its second turn carried the error + suggestion. + const secondTurn = model.calls[1]!.messages; + const toolMsg = secondTurn.find((m) => m.role === 'tool'); + expect(toolMsg?.text).toContain('no capability named "get_ordr"'); + expect(toolMsg?.text).toContain('get_order'); + }); +}); + +describe('runLoop — the ask/run write boundary', () => { + it('ask() structurally refuses a write before it executes (§2.1)', async () => { + deleteCalls = 0; + const model = new FakeModel( + callResponse('c1', 'delete_order', { id: 1 }), + finalResponse('should never reach here'), + ); + const reg = new CapabilityRegistry(); + reg.register(deleteCap()); + + const rep = await runLoop(model, reg, 'delete order 1', false); + expect(rep.outcome).toBe('refused'); + expect(rep.reason).toBe('write_in_read_only'); + expect(rep.executed).toEqual([]); + expect(deleteCalls).toBe(0); // the handler was never invoked + }); + + it('run() fails closed on a write until the M7.4 approval gate', async () => { + deleteCalls = 0; + const model = new FakeModel( + callResponse('c1', 'delete_order', { id: 1 }), + finalResponse('should never reach here'), + ); + const reg = new CapabilityRegistry(); + reg.register(deleteCap()); + + const rep = await runLoop(model, reg, 'delete order 1', true); + expect(rep.outcome).toBe('not_executed'); + expect(rep.reason).toBe('write_denied'); + expect(deleteCalls).toBe(0); + }); +}); + +describe('runLoop — budgets', () => { + it('stops a runaway loop at the turn cap', async () => { + // The model never answers — it keeps calling a read capability. + const model = new FakeModel( + ...Array.from({ length: 6 }, (_, i) => callResponse(`c${i}`, 'get_order', { id: 1 })), + ); + const reg = new CapabilityRegistry(); + reg.register(readCap()); + + const rep = await runLoop(model, reg, 'loop forever', false, { budget: { max_turns: 2 } }); + expect(rep.outcome).toBe('stopped'); + expect(rep.reason).toBe('budget_exhausted'); + expect(rep.turn).toBe(2); + }); +}); + +describe('Agent', () => { + it('ask() returns a RunResult with a trace id and answer', async () => { + const model = new FakeModel(finalResponse('Hello.')); + const agent = new Agent(model, { capabilities: [readCap()] }); + + const res = await agent.ask('say hi'); + expect(res.output.text).toBe('Hello.'); + expect(res.reason).toBe('stop'); + expect(res.trace_id).toMatch(/^[0-9a-f]{32}$/); + }); + + it('propagates the principal into capability calls (identity → RLS, §2.7)', async () => { + let seen: string | undefined; + const model = new FakeModel(callResponse('c1', 'get_order', { id: 1 }), finalResponse('done')); + const agent = new Agent(model, { + capabilities: [readCap((ctx) => (seen = ctx.principal))], + principal: 'user-42', + }); + + await agent.ask('who am i'); + expect(seen).toBe('user-42'); + }); + + it('throws on a duplicate capability name (control-plane)', () => { + const model = new FakeModel(); + expect(() => new Agent(model, { capabilities: [readCap(), readCap()] })).toThrow(); + }); +}); diff --git a/packages/js/tests/registry.test.ts b/packages/js/tests/registry.test.ts new file mode 100644 index 0000000..92300b3 --- /dev/null +++ b/packages/js/tests/registry.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect } from 'vitest'; +import { + CapabilityRegistry, + capability, + params, + closestMatches, + ReinsError, + type Context, +} from '../src/index.js'; + +const ctx: Context = {}; + +function orderRegistry(): CapabilityRegistry { + const reg = new CapabilityRegistry(); + reg.register( + capability( + 'get_order', + { description: 'Get an order by its id.', parameters: params({ id: 'integer' }) }, + (_c, args) => ({ id: args['id'], total: 42 }), + ), + ); + reg.register( + capability( + 'delete_order', + { description: 'Delete an order by its id.', parameters: params({ id: 'integer' }) }, + () => ({ deleted: true }), + ), + ); + return reg; +} + +describe('CapabilityRegistry', () => { + it('registers, lists in order, and exposes only the intent surface', () => { + const reg = orderRegistry(); + expect(reg.list().map((c) => c.name)).toEqual(['get_order', 'delete_order']); + for (const spec of reg.toolSpecs()) { + expect('access' in spec).toBe(false); + } + }); + + it('rejects a duplicate name (control-plane error)', () => { + const reg = orderRegistry(); + expect(() => reg.register(capability('get_order', { description: 'dup' }, () => null))).toThrow( + ReinsError, + ); + }); + + it('calls a capability and returns the result as data', async () => { + const reg = orderRegistry(); + const result = await reg.call('get_order', { id: 7 }, ctx); + expect(result).toEqual({ ok: true, value: { id: 7, total: 42 } }); + }); + + it('returns an unknown capability as data, with closest matches', async () => { + const reg = orderRegistry(); + const result = await reg.call('get_ordr', {}, ctx); + expect(result.ok).toBe(false); + expect(result.error).toContain('no capability named "get_ordr"'); + expect(result.error).toContain('get_order'); + }); + + it('surfaces a handler throw as errors-as-data (never over the loop)', async () => { + const reg = new CapabilityRegistry(); + reg.register( + capability('get_thing', { description: 'Explodes on purpose.' }, () => { + throw new Error('boom'); + }), + ); + const result = await reg.call('get_thing', {}, ctx); + expect(result).toEqual({ ok: false, error: 'boom', retryable: false }); + }); +}); + +describe('argument validation (§2.6 — model output is untrusted)', () => { + it('rejects a missing required argument', () => { + const reg = orderRegistry(); + expect(() => reg.validate('get_order', {})).toThrow(/missing required argument/); + }); + + it('rejects an unexpected argument when additionalProperties is false', () => { + const reg = orderRegistry(); + expect(() => reg.validate('get_order', { id: 1, sneaky: true })).toThrow(/unexpected argument/); + }); + + it('rejects a wrong argument type', () => { + const reg = orderRegistry(); + expect(() => reg.validate('get_order', { id: 'not-a-number' })).toThrow( + /must be of type integer/, + ); + }); + + it('accepts valid arguments', () => { + const reg = orderRegistry(); + expect(() => reg.validate('get_order', { id: 5 })).not.toThrow(); + }); + + it('returns a validation failure as data through call()', async () => { + const reg = orderRegistry(); + const result = await reg.call('get_order', { id: 'bad' }, ctx); + expect(result.ok).toBe(false); + expect(result.error).toContain('must be of type integer'); + }); +}); + +describe('closestMatches', () => { + it('ranks near names first and drops distant ones', () => { + const matches = closestMatches('get_ordr', ['get_order', 'delete_order', 'list_users'], 3); + expect(matches[0]).toBe('get_order'); // the nearest is first + expect(matches).not.toContain('list_users'); // too far to suggest + expect(closestMatches('zzzzzz', ['get_order'], 3)).toEqual([]); + }); +});