feat(reedy): Appendix A · Phase 2.1–2.3 — agent runtime foundation (#4298)
* feat(reedy): Appendix A · Phase 2.1 — ChatModel + model registry First piece of the deferred agent runtime, building alongside the shipped Phase 1 MVP per the locked decision (no rip-and-replace; legacy + Reedy MVP + agent paths coexist). ChatModel is a thin interface over Vercel SDK's LanguageModel that surfaces contextWindow / reservedOutput / supportsTools — the metadata the M2.5 PromptContextBuilder and M2.6 AgentRuntime need for prompt budgeting and tool-calling decisions. createReedyModels(settings) returns the active (chat, embedding) pair from the user's currently configured AIProvider, rewrapped in the Reedy interfaces without touching the legacy AIProvider. CONTEXT_WINDOW_TABLE hardcodes per-model metadata (Gemini 2.5 → 2M, GPT-5/Llama-4 → 128K, local Ollama llama → 4K with tools off, etc.) with a conservative 8K fallback for unknown ids. No runtime wired yet — Phase 2.6 consumes this. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(reedy): Appendix A · Phases 2.2–2.3 — runtime event/error taxonomy + ToolRegistry Phase 2.2 — pure types and factories for everything AgentRuntime.runTurn() will yield: turn_start | text_delta | tool_call | tool_result(ok/err) | citation | memory_write | step_finish | usage | error | abort | done ReedyError covers turn-level failures (context_overflow, turn_timeout, model_error, provider_unavailable, stream_parse, abort, ...) with default retryability per kind so the M2.6 streamLoop's retry-with-backoff can decide without string-matching. ReedyToolError covers tool-execution failures with a narrower kind union so the streamLoop can re-emit them as { tool_result, ok: false, error } events without losing structure. Phase 2.3 — ToolRegistry that holds the runtime's tool catalog and adapts each tool to the Vercel ai-SDK ToolSet streamText consumes. Every registered ReedyTool gets wrapped with: - Zod input validation → tool_invalid_args on mismatch - Permission gate → tool_permission_denied (read auto-approves) - Per-call wall-clock → tool_timeout (default 10s) - AbortSignal propagation → tool_aborted on turn cancel - Per-tool serialization → parallelSafe=false tools queue The local AbortController per call composes the turn-level signal with the per-tool timeout so we classify timeout-vs-abort correctly based on which controller fired first, and listener cleanup avoids leaks. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,249 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { z } from 'zod';
|
||||
import { ToolRegistry } from '@/services/reedy/tools/ToolRegistry';
|
||||
import type { ReedyTool, ToolContext } from '@/services/reedy/tools/types';
|
||||
import type { ReedyToolError } from '@/services/reedy/runtime/errors';
|
||||
|
||||
function ctxFor(overrides: Partial<ToolContext> = {}): ToolContext {
|
||||
const controller = new AbortController();
|
||||
return {
|
||||
bookHash: 'bk1',
|
||||
sessionId: 's1',
|
||||
assistantMessageId: 'm1',
|
||||
signal: controller.signal,
|
||||
requestPermission: vi.fn(async () => true),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function readTool(name = 'lookupPassage'): ReedyTool<{ q: string }, { hit: string }> {
|
||||
return {
|
||||
name,
|
||||
description: 'find passages',
|
||||
permission: 'read',
|
||||
parallelSafe: true,
|
||||
inputSchema: z.object({ q: z.string().min(1) }),
|
||||
async run(args) {
|
||||
return { hit: args.q };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('ToolRegistry', () => {
|
||||
let reg: ToolRegistry;
|
||||
|
||||
beforeEach(() => {
|
||||
reg = new ToolRegistry();
|
||||
});
|
||||
|
||||
describe('basic CRUD', () => {
|
||||
it('register + get + list round-trip', () => {
|
||||
const t = readTool();
|
||||
reg.register(t);
|
||||
expect(reg.get('lookupPassage')).toBe(t);
|
||||
expect(reg.list()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('throws when registering the same name twice', () => {
|
||||
reg.register(readTool());
|
||||
expect(() => reg.register(readTool())).toThrow(/already registered/);
|
||||
});
|
||||
|
||||
it('unregister returns whether the name existed', () => {
|
||||
reg.register(readTool());
|
||||
expect(reg.unregister('lookupPassage')).toBe(true);
|
||||
expect(reg.unregister('lookupPassage')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Zod validation', () => {
|
||||
it('throws ReedyToolError with kind=tool_invalid_args on malformed args', async () => {
|
||||
reg.register(readTool());
|
||||
let caught: ReedyToolError | undefined;
|
||||
try {
|
||||
await reg.invoke('lookupPassage', { q: '' }, ctxFor());
|
||||
} catch (err) {
|
||||
caught = err as ReedyToolError;
|
||||
}
|
||||
expect(caught).toBeDefined();
|
||||
expect(caught!.kind).toBe('tool_invalid_args');
|
||||
expect(caught!.toolName).toBe('lookupPassage');
|
||||
});
|
||||
});
|
||||
|
||||
describe('permission gate', () => {
|
||||
it('read-only tools skip the permission prompt', async () => {
|
||||
reg.register(readTool());
|
||||
const reqPerm = vi.fn(async () => true);
|
||||
const ctx = ctxFor({ requestPermission: reqPerm });
|
||||
await reg.invoke('lookupPassage', { q: 'hi' }, ctx);
|
||||
expect(reqPerm).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('navigate / write tools prompt for permission and throw on denial', async () => {
|
||||
const navTool: ReedyTool<{ cfi: string }, void> = {
|
||||
name: 'navigateToCfi',
|
||||
description: 'go to CFI',
|
||||
permission: 'navigate',
|
||||
parallelSafe: false,
|
||||
inputSchema: z.object({ cfi: z.string() }),
|
||||
async run() {},
|
||||
};
|
||||
reg.register(navTool);
|
||||
const ctx = ctxFor({ requestPermission: vi.fn(async () => false) });
|
||||
|
||||
await expect(reg.invoke('navigateToCfi', { cfi: 'x' }, ctx)).rejects.toMatchObject({
|
||||
kind: 'tool_permission_denied',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('per-call timeout', () => {
|
||||
it('throws ReedyToolError with kind=tool_timeout when the tool exceeds timeoutMs', async () => {
|
||||
const slow: ReedyTool<{ q: string }, string> = {
|
||||
name: 'slow',
|
||||
description: 'slow',
|
||||
permission: 'read',
|
||||
parallelSafe: true,
|
||||
timeoutMs: 20,
|
||||
inputSchema: z.object({ q: z.string() }),
|
||||
async run(_args, ctx) {
|
||||
await new Promise((resolve, reject) => {
|
||||
const t = setTimeout(resolve, 100);
|
||||
ctx.signal.addEventListener('abort', () => {
|
||||
clearTimeout(t);
|
||||
reject(new Error('aborted'));
|
||||
});
|
||||
});
|
||||
return 'done';
|
||||
},
|
||||
};
|
||||
reg.register(slow);
|
||||
await expect(reg.invoke('slow', { q: 'x' }, ctxFor())).rejects.toMatchObject({
|
||||
kind: 'tool_timeout',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('abort propagation', () => {
|
||||
it('throws ReedyToolError with kind=tool_aborted when the turn signal fires', async () => {
|
||||
const controller = new AbortController();
|
||||
const t: ReedyTool<{ q: string }, string> = {
|
||||
name: 'wait',
|
||||
description: 'wait',
|
||||
permission: 'read',
|
||||
parallelSafe: true,
|
||||
inputSchema: z.object({ q: z.string() }),
|
||||
async run(_args, ctx) {
|
||||
await new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(resolve, 100);
|
||||
ctx.signal.addEventListener('abort', () => {
|
||||
clearTimeout(timer);
|
||||
reject(new Error('aborted'));
|
||||
});
|
||||
});
|
||||
return 'ok';
|
||||
},
|
||||
};
|
||||
reg.register(t);
|
||||
const pending = reg.invoke('wait', { q: 'x' }, ctxFor({ signal: controller.signal }));
|
||||
controller.abort();
|
||||
await expect(pending).rejects.toMatchObject({ kind: 'tool_aborted' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('parallel serialization', () => {
|
||||
it('parallelSafe=false serializes overlapping invocations of the same tool', async () => {
|
||||
let active = 0;
|
||||
let maxActive = 0;
|
||||
const t: ReedyTool<{ q: string }, void> = {
|
||||
name: 'serial',
|
||||
description: 'serial',
|
||||
permission: 'read',
|
||||
parallelSafe: false,
|
||||
inputSchema: z.object({ q: z.string() }),
|
||||
async run() {
|
||||
active++;
|
||||
maxActive = Math.max(maxActive, active);
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
active--;
|
||||
},
|
||||
};
|
||||
reg.register(t);
|
||||
const ctx = ctxFor();
|
||||
await Promise.all([
|
||||
reg.invoke('serial', { q: 'a' }, ctx),
|
||||
reg.invoke('serial', { q: 'b' }, ctx),
|
||||
reg.invoke('serial', { q: 'c' }, ctx),
|
||||
]);
|
||||
expect(maxActive).toBe(1);
|
||||
});
|
||||
|
||||
it('parallelSafe=true allows overlapping invocations of the same tool', async () => {
|
||||
let active = 0;
|
||||
let maxActive = 0;
|
||||
const t: ReedyTool<{ q: string }, void> = {
|
||||
name: 'par',
|
||||
description: 'par',
|
||||
permission: 'read',
|
||||
parallelSafe: true,
|
||||
inputSchema: z.object({ q: z.string() }),
|
||||
async run() {
|
||||
active++;
|
||||
maxActive = Math.max(maxActive, active);
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
active--;
|
||||
},
|
||||
};
|
||||
reg.register(t);
|
||||
const ctx = ctxFor();
|
||||
await Promise.all([
|
||||
reg.invoke('par', { q: 'a' }, ctx),
|
||||
reg.invoke('par', { q: 'b' }, ctx),
|
||||
reg.invoke('par', { q: 'c' }, ctx),
|
||||
]);
|
||||
expect(maxActive).toBeGreaterThan(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toVercelToolSet', () => {
|
||||
it('returns a Vercel ToolSet shape with one entry per registered tool', () => {
|
||||
reg.register(readTool('a'));
|
||||
reg.register(readTool('b'));
|
||||
const set = reg.toVercelToolSet(ctxFor()) as Record<string, { execute?: unknown }>;
|
||||
expect(Object.keys(set).sort()).toEqual(['a', 'b']);
|
||||
expect(typeof set['a']!.execute).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('unknown / runtime errors', () => {
|
||||
it('invoking an unregistered name throws tool_unknown', async () => {
|
||||
await expect(reg.invoke('missing', {}, ctxFor())).rejects.toMatchObject({
|
||||
kind: 'tool_unknown',
|
||||
});
|
||||
});
|
||||
|
||||
it('a thrown tool error is wrapped as tool_runtime_error with cause preserved', async () => {
|
||||
const root = new Error('explode');
|
||||
const t: ReedyTool<{ q: string }, void> = {
|
||||
name: 'boom',
|
||||
description: 'boom',
|
||||
permission: 'read',
|
||||
parallelSafe: true,
|
||||
inputSchema: z.object({ q: z.string() }),
|
||||
async run() {
|
||||
throw root;
|
||||
},
|
||||
};
|
||||
reg.register(t);
|
||||
let caught: ReedyToolError | undefined;
|
||||
try {
|
||||
await reg.invoke('boom', { q: 'x' }, ctxFor());
|
||||
} catch (err) {
|
||||
caught = err as ReedyToolError;
|
||||
}
|
||||
expect(caught!.kind).toBe('tool_runtime_error');
|
||||
expect(caught!.cause).toBe(root);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { createReedyModels } from '@/services/reedy/models/registry';
|
||||
import { DEFAULT_AI_SETTINGS } from '@/services/ai/constants';
|
||||
import type { AISettings } from '@/services/ai/types';
|
||||
|
||||
// Mock the AIProvider factory so we don't pull provider transports into the
|
||||
// registry test — registry's job is metadata + routing, not provider plumbing.
|
||||
vi.mock('@/services/ai/providers', () => ({
|
||||
getAIProvider: () => ({
|
||||
getModel: () => ({ __mock: 'language-model' }),
|
||||
getEmbeddingModel: () => ({ __mock: 'embedding-model' }),
|
||||
}),
|
||||
}));
|
||||
|
||||
function settings(overrides: Partial<AISettings> = {}): AISettings {
|
||||
return { ...DEFAULT_AI_SETTINGS, enabled: true, ...overrides };
|
||||
}
|
||||
|
||||
describe('createReedyModels — chat metadata table', () => {
|
||||
it('routes Gemini 2.5 models to the 2M context window', () => {
|
||||
const { chat } = createReedyModels(
|
||||
settings({ provider: 'ai-gateway', aiGatewayModel: 'google/gemini-2.5-flash-lite' }),
|
||||
);
|
||||
expect(chat.id).toBe('google/gemini-2.5-flash-lite');
|
||||
expect(chat.contextWindow).toBe(2_000_000);
|
||||
expect(chat.supportsTools).toBe(true);
|
||||
});
|
||||
|
||||
it('routes GPT-5 family to a 128K context window', () => {
|
||||
const { chat } = createReedyModels(
|
||||
settings({ provider: 'ai-gateway', aiGatewayModel: 'openai/gpt-5-nano' }),
|
||||
);
|
||||
expect(chat.contextWindow).toBe(128_000);
|
||||
expect(chat.supportsTools).toBe(true);
|
||||
});
|
||||
|
||||
it('routes Llama 4 to 128K with tool support', () => {
|
||||
const { chat } = createReedyModels(
|
||||
settings({ provider: 'ai-gateway', aiGatewayModel: 'meta/llama-4-scout' }),
|
||||
);
|
||||
expect(chat.contextWindow).toBe(128_000);
|
||||
expect(chat.supportsTools).toBe(true);
|
||||
});
|
||||
|
||||
it('treats local Ollama llama models as 4K ctx, no tool support', () => {
|
||||
const { chat } = createReedyModels(settings({ provider: 'ollama', ollamaModel: 'llama3.2' }));
|
||||
expect(chat.id).toBe('llama3.2');
|
||||
expect(chat.contextWindow).toBe(4_096);
|
||||
expect(chat.supportsTools).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back to a conservative 8K default for unknown model ids', () => {
|
||||
const { chat } = createReedyModels(
|
||||
settings({ provider: 'ai-gateway', aiGatewayModel: 'unknown/strange-future-model' }),
|
||||
);
|
||||
expect(chat.contextWindow).toBe(8_192);
|
||||
expect(chat.supportsTools).toBe(false);
|
||||
});
|
||||
|
||||
it('prefers the AI Gateway custom-model field over the dropdown selection', () => {
|
||||
const { chat } = createReedyModels(
|
||||
settings({
|
||||
provider: 'ai-gateway',
|
||||
aiGatewayModel: 'openai/gpt-5-nano',
|
||||
aiGatewayCustomModel: 'anthropic/claude-opus-4-7',
|
||||
}),
|
||||
);
|
||||
expect(chat.id).toBe('anthropic/claude-opus-4-7');
|
||||
expect(chat.contextWindow).toBe(200_000);
|
||||
});
|
||||
|
||||
it('returns the underlying Vercel SDK model via getLanguageModel()', () => {
|
||||
const { chat } = createReedyModels(settings({ provider: 'ai-gateway' }));
|
||||
expect(chat.getLanguageModel()).toEqual({ __mock: 'language-model' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('createReedyModels — embedding routing', () => {
|
||||
it('uses ollama embedding setting for the ollama provider', () => {
|
||||
const { embedding } = createReedyModels(
|
||||
settings({ provider: 'ollama', ollamaEmbeddingModel: 'mxbai-embed-large' }),
|
||||
);
|
||||
expect(embedding.id).toBe('mxbai-embed-large');
|
||||
expect(embedding.batchSize).toBe(4);
|
||||
});
|
||||
|
||||
it('uses larger batchSize for hosted providers', () => {
|
||||
const { embedding } = createReedyModels(settings({ provider: 'ai-gateway' }));
|
||||
expect(embedding.batchSize).toBe(16);
|
||||
});
|
||||
|
||||
it('falls back to text-embedding-3-small when openrouter embedding is empty', () => {
|
||||
const { embedding } = createReedyModels(
|
||||
settings({ provider: 'openrouter', openrouterEmbeddingModel: '' }),
|
||||
);
|
||||
expect(embedding.id).toBe('openai/text-embedding-3-small');
|
||||
});
|
||||
|
||||
it('embedding.dim throws if called before any embed() round-trip', () => {
|
||||
const { embedding } = createReedyModels(settings({ provider: 'ai-gateway' }));
|
||||
expect(() => embedding.dim).toThrow(/dim unknown/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { events, type ReedyEvent } from '@/services/reedy/runtime/events';
|
||||
import {
|
||||
ReedyError,
|
||||
ReedyToolError,
|
||||
makeReedyError,
|
||||
makeToolError,
|
||||
} from '@/services/reedy/runtime/errors';
|
||||
|
||||
describe('ReedyError', () => {
|
||||
it('makeReedyError sets retryable per the default table when unspecified', () => {
|
||||
expect(makeReedyError('context_overflow', 'too big').retryable).toBe(true);
|
||||
expect(makeReedyError('provider_unavailable', 'down').retryable).toBe(true);
|
||||
expect(makeReedyError('turn_timeout', '5m hit').retryable).toBe(false);
|
||||
expect(makeReedyError('invalid_response', 'bad').retryable).toBe(false);
|
||||
});
|
||||
|
||||
it('allows the caller to override retryable explicitly', () => {
|
||||
expect(makeReedyError('context_overflow', 'x', { retryable: false }).retryable).toBe(false);
|
||||
expect(makeReedyError('turn_timeout', 'x', { retryable: true }).retryable).toBe(true);
|
||||
});
|
||||
|
||||
it('preserves cause for upstream debugging', () => {
|
||||
const root = new Error('underlying');
|
||||
const err = makeReedyError('model_error', 'wrapped', { cause: root });
|
||||
expect(err.cause).toBe(root);
|
||||
});
|
||||
|
||||
it('isAbort returns true only for kind=abort ReedyError', () => {
|
||||
expect(ReedyError.isAbort(makeReedyError('abort', 'cancelled'))).toBe(true);
|
||||
expect(ReedyError.isAbort(makeReedyError('model_error', 'other'))).toBe(false);
|
||||
expect(ReedyError.isAbort(new Error('plain'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ReedyToolError', () => {
|
||||
it('defaults retryable=true only for tool_invalid_args and tool_timeout', () => {
|
||||
expect(makeToolError('tool_invalid_args', 'lookupPassage', 'bad').retryable).toBe(true);
|
||||
expect(makeToolError('tool_timeout', 'lookupPassage', 'slow').retryable).toBe(true);
|
||||
expect(makeToolError('tool_permission_denied', 'navigate', 'denied').retryable).toBe(false);
|
||||
expect(makeToolError('tool_runtime_error', 'lookupPassage', 'crash').retryable).toBe(false);
|
||||
});
|
||||
|
||||
it('exposes toolName + kind on the instance', () => {
|
||||
const err = makeToolError('tool_timeout', 'lookupPassage', 'slow');
|
||||
expect(err.toolName).toBe('lookupPassage');
|
||||
expect(err.kind).toBe('tool_timeout');
|
||||
expect(err.name).toBe('ReedyToolError');
|
||||
});
|
||||
});
|
||||
|
||||
describe('events factories', () => {
|
||||
it('emit the expected shape for every variant', () => {
|
||||
const out: ReedyEvent[] = [
|
||||
events.turnStart('s1', 'msg1'),
|
||||
events.textDelta('hi'),
|
||||
events.toolCall({ id: 't1', name: 'lookupPassage', args: { q: 'a' }, permission: 'read' }),
|
||||
events.toolResultOk({ id: 't1', name: 'lookupPassage', result: { x: 1 }, durationMs: 42 }),
|
||||
events.toolResultErr({
|
||||
id: 't2',
|
||||
name: 'lookupPassage',
|
||||
error: makeToolError('tool_timeout', 'lookupPassage', 'slow'),
|
||||
}),
|
||||
events.citation({ cfi: 'epubcfi(/6/2!)', sectionIndex: 0, snippet: 'hi' }),
|
||||
events.memoryWrite('book', 'theme', 'about loss'),
|
||||
events.stepFinish(1, 'tool-calls'),
|
||||
events.usage(500, 200),
|
||||
events.error('model_error', 'oops', true),
|
||||
events.abort(true),
|
||||
events.done({
|
||||
sessionId: 's1',
|
||||
assistantMessageId: 'msg1',
|
||||
finishReason: 'stop',
|
||||
usage: { promptTokens: 500, completionTokens: 200 },
|
||||
}),
|
||||
];
|
||||
|
||||
expect(out.map((e) => e.type)).toEqual([
|
||||
'turn_start',
|
||||
'text_delta',
|
||||
'tool_call',
|
||||
'tool_result',
|
||||
'tool_result',
|
||||
'citation',
|
||||
'memory_write',
|
||||
'step_finish',
|
||||
'usage',
|
||||
'error',
|
||||
'abort',
|
||||
'done',
|
||||
]);
|
||||
});
|
||||
|
||||
it('toolResultOk carries ok=true; toolResultErr carries ok=false + error', () => {
|
||||
const okEv = events.toolResultOk({ id: 't', name: 'n', result: 1, durationMs: 0 });
|
||||
const errEv = events.toolResultErr({
|
||||
id: 't',
|
||||
name: 'n',
|
||||
error: makeToolError('tool_runtime_error', 'n', 'boom'),
|
||||
});
|
||||
expect(okEv).toMatchObject({ type: 'tool_result', ok: true });
|
||||
expect(errEv).toMatchObject({ type: 'tool_result', ok: false });
|
||||
if (errEv.type === 'tool_result' && !errEv.ok) {
|
||||
expect(errEv.error).toBeInstanceOf(ReedyToolError);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { LanguageModel } from 'ai';
|
||||
|
||||
/**
|
||||
* Chat-model surface the agent runtime talks to.
|
||||
*
|
||||
* Appendix A · Phase 2.1 — a thin wrapper around the Vercel `ai` SDK's
|
||||
* `LanguageModel` that adds the metadata the runtime needs for prompt
|
||||
* budgeting and tool-calling decisions. The actual streamText/generateText
|
||||
* invocation still happens against `getLanguageModel()`, so we don't
|
||||
* re-implement provider transports here.
|
||||
*
|
||||
* Lives alongside the MVP (per the "Build Phases 2-5 alongside MVP" decision):
|
||||
* the legacy `AIProvider` interface in `src/services/ai/types.ts` is unchanged
|
||||
* and continues to power Phase 1B's TauriChatAdapter.
|
||||
*/
|
||||
export interface ChatModel {
|
||||
/** Stable identifier — matches the model field in AISettings. */
|
||||
readonly id: string;
|
||||
|
||||
/**
|
||||
* Maximum input + output tokens the model accepts. Used by the M2.5
|
||||
* PromptContextBuilder to decide what to shrink. Hardcoded per-model in
|
||||
* registry.ts; ChatModel callers should never have to probe.
|
||||
*/
|
||||
readonly contextWindow: number;
|
||||
|
||||
/**
|
||||
* Tokens reserved for completion. The PromptContextBuilder budgets to
|
||||
* `contextWindow - reservedOutput - safetyMargin`. Defaults to 1024 for
|
||||
* most models; reasoning models reserve more.
|
||||
*/
|
||||
readonly reservedOutput: number;
|
||||
|
||||
/**
|
||||
* Whether the model supports Vercel-SDK tool calling. The runtime falls
|
||||
* back to system-prompt-injection mode when false, which matches the
|
||||
* MVP's legacy IDB path.
|
||||
*/
|
||||
readonly supportsTools: boolean;
|
||||
|
||||
/** Underlying Vercel SDK model; pass to streamText / generateText. */
|
||||
getLanguageModel(): LanguageModel;
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import { embed, embedMany } from 'ai';
|
||||
import { getAIProvider } from '@/services/ai/providers';
|
||||
import type { AISettings } from '@/services/ai/types';
|
||||
import type { ChatModel } from './ChatModel';
|
||||
import type { EmbeddingModel } from './EmbeddingModel';
|
||||
|
||||
/**
|
||||
* Pair of (chat, embedding) models the agent runtime uses for one turn.
|
||||
* Both come from the user's currently active AIProvider, just rewrapped to
|
||||
* the narrower Reedy interfaces so the runtime depends on metadata-bearing
|
||||
* shapes rather than the provider-shape that still serves the legacy path.
|
||||
*/
|
||||
export interface ReedyModels {
|
||||
chat: ChatModel;
|
||||
embedding: EmbeddingModel;
|
||||
}
|
||||
|
||||
export function createReedyModels(settings: AISettings): ReedyModels {
|
||||
const provider = getAIProvider(settings);
|
||||
return {
|
||||
chat: adaptChatModel(provider.getModel(), chatModelIdFor(settings)),
|
||||
embedding: adaptEmbeddingModel(
|
||||
provider.getEmbeddingModel(),
|
||||
embeddingModelIdFor(settings),
|
||||
settings,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ChatModel adapter
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Per-model context-window metadata. Hardcoded because the Vercel SDK's
|
||||
* LanguageModel interface doesn't expose this — the SDK serializes the
|
||||
* model id and lets the provider decide. We keep a small table here keyed
|
||||
* on either the provider model id or a substring; unknown models get a
|
||||
* conservative default.
|
||||
*
|
||||
* Values from each provider's docs as of 2026-05; refresh as new families
|
||||
* land. Tokens are claimed maxes — for prompt budgeting we subtract
|
||||
* `reservedOutput` and a safety margin upstream in PromptContextBuilder.
|
||||
*/
|
||||
const CONTEXT_WINDOW_TABLE: Array<{
|
||||
match: (id: string) => boolean;
|
||||
contextWindow: number;
|
||||
reservedOutput: number;
|
||||
supportsTools: boolean;
|
||||
}> = [
|
||||
// Google
|
||||
{
|
||||
match: (id) => id.includes('gemini-2.5') || id.includes('gemini-3'),
|
||||
contextWindow: 2_000_000,
|
||||
reservedOutput: 8_192,
|
||||
supportsTools: true,
|
||||
},
|
||||
// OpenAI
|
||||
{
|
||||
match: (id) => id.includes('gpt-5') || id.includes('gpt-4.1') || id.includes('gpt-4o'),
|
||||
contextWindow: 128_000,
|
||||
reservedOutput: 4_096,
|
||||
supportsTools: true,
|
||||
},
|
||||
// Anthropic
|
||||
{
|
||||
match: (id) => id.includes('claude-opus') || id.includes('claude-sonnet'),
|
||||
contextWindow: 200_000,
|
||||
reservedOutput: 8_192,
|
||||
supportsTools: true,
|
||||
},
|
||||
// Meta
|
||||
{
|
||||
match: (id) => id.includes('llama-4') || id.includes('llama-3.3'),
|
||||
contextWindow: 128_000,
|
||||
reservedOutput: 2_048,
|
||||
supportsTools: true,
|
||||
},
|
||||
// DeepSeek + Qwen + Grok — all support tool calls in the 2025+ generations
|
||||
{
|
||||
match: (id) => id.includes('deepseek-v3') || id.includes('qwen-3') || id.includes('grok-4'),
|
||||
contextWindow: 128_000,
|
||||
reservedOutput: 2_048,
|
||||
supportsTools: true,
|
||||
},
|
||||
// Local Ollama defaults — most models default to a 4K ctx unless the
|
||||
// user sets Modelfile params. Tool support varies; treat as off.
|
||||
{
|
||||
match: (id) => id.startsWith('llama') || id.includes('mistral') || id.includes('phi'),
|
||||
contextWindow: 4_096,
|
||||
reservedOutput: 1_024,
|
||||
supportsTools: false,
|
||||
},
|
||||
];
|
||||
|
||||
const DEFAULT_CHAT_METADATA = {
|
||||
contextWindow: 8_192,
|
||||
reservedOutput: 1_024,
|
||||
supportsTools: false,
|
||||
};
|
||||
|
||||
function adaptChatModel(languageModel: import('ai').LanguageModel, id: string): ChatModel {
|
||||
const meta = CONTEXT_WINDOW_TABLE.find((row) => row.match(id)) ?? DEFAULT_CHAT_METADATA;
|
||||
return {
|
||||
id,
|
||||
contextWindow: meta.contextWindow,
|
||||
reservedOutput: meta.reservedOutput,
|
||||
supportsTools: meta.supportsTools,
|
||||
getLanguageModel: () => languageModel,
|
||||
};
|
||||
}
|
||||
|
||||
function chatModelIdFor(settings: AISettings): string {
|
||||
switch (settings.provider) {
|
||||
case 'ollama':
|
||||
return settings.ollamaModel || 'llama3.2';
|
||||
case 'ai-gateway':
|
||||
return (
|
||||
settings.aiGatewayCustomModel || settings.aiGatewayModel || 'google/gemini-2.5-flash-lite'
|
||||
);
|
||||
case 'openrouter':
|
||||
return settings.openrouterModel || 'openai/gpt-4o-mini';
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// EmbeddingModel adapter
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function adaptEmbeddingModel(
|
||||
vercelModel: import('ai').EmbeddingModel,
|
||||
id: string,
|
||||
settings: AISettings,
|
||||
): EmbeddingModel {
|
||||
const batchSize = settings.provider === 'ollama' ? 4 : 16;
|
||||
let dim: number | null = null;
|
||||
return {
|
||||
id,
|
||||
get dim(): number {
|
||||
if (dim == null) {
|
||||
throw new Error('embedding dim unknown — call embed([sample]) once before reading dim');
|
||||
}
|
||||
return dim;
|
||||
},
|
||||
batchSize,
|
||||
async embed(texts, opts) {
|
||||
if (texts.length === 0) return [];
|
||||
if (texts.length === 1) {
|
||||
const { embedding } = await embed({
|
||||
model: vercelModel,
|
||||
value: texts[0]!,
|
||||
abortSignal: opts?.signal,
|
||||
});
|
||||
dim ??= embedding.length;
|
||||
return [embedding];
|
||||
}
|
||||
const { embeddings } = await embedMany({
|
||||
model: vercelModel,
|
||||
values: texts,
|
||||
abortSignal: opts?.signal,
|
||||
});
|
||||
if (embeddings.length > 0) dim ??= embeddings[0]!.length;
|
||||
return embeddings;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function embeddingModelIdFor(settings: AISettings): string {
|
||||
switch (settings.provider) {
|
||||
case 'ollama':
|
||||
return settings.ollamaEmbeddingModel || 'nomic-embed-text';
|
||||
case 'ai-gateway':
|
||||
return settings.aiGatewayEmbeddingModel || 'openai/text-embedding-3-small';
|
||||
case 'openrouter':
|
||||
return settings.openrouterEmbeddingModel || 'openai/text-embedding-3-small';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Error taxonomy for the Reedy agent runtime (Appendix A · Phase 2.2).
|
||||
*
|
||||
* Three layers:
|
||||
* - `ReedyErrorKind` enumerates everything the runtime can hit at the
|
||||
* outer (turn-lifecycle) level: provider failures, context overflow,
|
||||
* timeout, abort, etc.
|
||||
* - `ReedyError` is the catch-all class — carries `kind` + `retryable`
|
||||
* so the runtime's retry/backoff loop can decide what to do without
|
||||
* string-matching.
|
||||
* - `ReedyToolError` is a narrower class for tool execution failures
|
||||
* so the streamLoop can re-emit them as `{ type: 'tool_result', ok: false }`
|
||||
* events without losing structure.
|
||||
*/
|
||||
|
||||
export type ReedyErrorKind =
|
||||
| 'context_overflow'
|
||||
| 'turn_timeout'
|
||||
| 'model_error'
|
||||
| 'provider_unavailable'
|
||||
| 'invalid_response'
|
||||
| 'stream_parse'
|
||||
| 'abort'
|
||||
| 'unknown';
|
||||
|
||||
export interface ReedyErrorOptions {
|
||||
kind: ReedyErrorKind;
|
||||
retryable: boolean;
|
||||
cause?: unknown;
|
||||
}
|
||||
|
||||
export class ReedyError extends Error {
|
||||
readonly kind: ReedyErrorKind;
|
||||
readonly retryable: boolean;
|
||||
override readonly cause?: unknown;
|
||||
|
||||
constructor(message: string, opts: ReedyErrorOptions) {
|
||||
super(message);
|
||||
this.name = 'ReedyError';
|
||||
this.kind = opts.kind;
|
||||
this.retryable = opts.retryable;
|
||||
this.cause = opts.cause;
|
||||
}
|
||||
|
||||
/** True when the error was raised by aborting the turn via AbortSignal. */
|
||||
static isAbort(err: unknown): err is ReedyError {
|
||||
return err instanceof ReedyError && err.kind === 'abort';
|
||||
}
|
||||
}
|
||||
|
||||
export type ReedyToolErrorKind =
|
||||
| 'tool_invalid_args' // Zod schema rejection
|
||||
| 'tool_permission_denied' // navigate / write was refused by the user
|
||||
| 'tool_timeout' // per-tool wall-clock budget hit
|
||||
| 'tool_aborted' // turn-level AbortSignal cancelled the tool call
|
||||
| 'tool_runtime_error' // tool's run() threw
|
||||
| 'tool_unknown';
|
||||
|
||||
export interface ReedyToolErrorOptions {
|
||||
kind: ReedyToolErrorKind;
|
||||
toolName: string;
|
||||
retryable?: boolean;
|
||||
cause?: unknown;
|
||||
}
|
||||
|
||||
export class ReedyToolError extends Error {
|
||||
readonly kind: ReedyToolErrorKind;
|
||||
readonly toolName: string;
|
||||
readonly retryable: boolean;
|
||||
override readonly cause?: unknown;
|
||||
|
||||
constructor(message: string, opts: ReedyToolErrorOptions) {
|
||||
super(message);
|
||||
this.name = 'ReedyToolError';
|
||||
this.kind = opts.kind;
|
||||
this.toolName = opts.toolName;
|
||||
// Default: only invalid args + transient timeouts are retryable; the
|
||||
// model can correct its own call without intervention.
|
||||
this.retryable =
|
||||
opts.retryable ?? (opts.kind === 'tool_invalid_args' || opts.kind === 'tool_timeout');
|
||||
this.cause = opts.cause;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Factories (used by streamLoop / ToolRegistry — see Phase 2.6 / 2.3)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function makeReedyError(
|
||||
kind: ReedyErrorKind,
|
||||
message: string,
|
||||
opts: { retryable?: boolean; cause?: unknown } = {},
|
||||
): ReedyError {
|
||||
const defaultRetryable: Record<ReedyErrorKind, boolean> = {
|
||||
context_overflow: true,
|
||||
turn_timeout: false,
|
||||
model_error: true,
|
||||
provider_unavailable: true,
|
||||
invalid_response: false,
|
||||
stream_parse: false,
|
||||
abort: false,
|
||||
unknown: false,
|
||||
};
|
||||
return new ReedyError(message, {
|
||||
kind,
|
||||
retryable: opts.retryable ?? defaultRetryable[kind],
|
||||
cause: opts.cause,
|
||||
});
|
||||
}
|
||||
|
||||
export function makeToolError(
|
||||
kind: ReedyToolErrorKind,
|
||||
toolName: string,
|
||||
message: string,
|
||||
cause?: unknown,
|
||||
): ReedyToolError {
|
||||
return new ReedyToolError(message, { kind, toolName, cause });
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import type { ReedyErrorKind, ReedyToolError } from './errors';
|
||||
|
||||
/**
|
||||
* Permission level a tool requires before the runtime invokes it. Matches
|
||||
* the ToolRegistry's `permission` field (Phase 2.3). Read-only tools are
|
||||
* auto-approved; navigate prompts once per session per the v1 UX in §10;
|
||||
* write prompts inline.
|
||||
*/
|
||||
export type PermissionLevel = 'read' | 'navigate' | 'write';
|
||||
|
||||
/**
|
||||
* Discriminated union of everything `AgentRuntime.runTurn()` yields.
|
||||
* See §6 of the planning doc for the canonical contract; the agent UI in
|
||||
* Phase 4 consumes this stream and dispatches store updates per event.
|
||||
*/
|
||||
export type ReedyEvent =
|
||||
| { type: 'turn_start'; sessionId: string; assistantMessageId: string }
|
||||
| { type: 'text_delta'; delta: string }
|
||||
| {
|
||||
type: 'tool_call';
|
||||
id: string;
|
||||
name: string;
|
||||
args: unknown;
|
||||
permission: PermissionLevel;
|
||||
}
|
||||
| {
|
||||
type: 'tool_result';
|
||||
id: string;
|
||||
name: string;
|
||||
ok: true;
|
||||
result: unknown;
|
||||
durationMs: number;
|
||||
}
|
||||
| {
|
||||
type: 'tool_result';
|
||||
id: string;
|
||||
name: string;
|
||||
ok: false;
|
||||
error: ReedyToolError;
|
||||
}
|
||||
| {
|
||||
type: 'citation';
|
||||
cfi: string;
|
||||
sectionIndex: number;
|
||||
chapterTitle?: string;
|
||||
snippet: string;
|
||||
}
|
||||
| { type: 'memory_write'; scope: 'user' | 'book'; key: string; summary: string }
|
||||
| { type: 'step_finish'; step: number; reason: 'stop' | 'tool-calls' | 'length' }
|
||||
| { type: 'usage'; promptTokens: number; completionTokens: number }
|
||||
| { type: 'error'; kind: ReedyErrorKind; message: string; retryable: boolean }
|
||||
| { type: 'abort'; partial: boolean }
|
||||
| { type: 'done'; output: ReedyTurnOutput };
|
||||
|
||||
export interface ReedyTurnOutput {
|
||||
sessionId: string;
|
||||
assistantMessageId: string;
|
||||
finishReason: 'stop' | 'length' | 'tool-error' | 'abort' | 'error';
|
||||
usage?: { promptTokens: number; completionTokens: number };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Factories — keeping the call sites in streamLoop terse and refactor-safe.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const events = {
|
||||
turnStart(sessionId: string, assistantMessageId: string): ReedyEvent {
|
||||
return { type: 'turn_start', sessionId, assistantMessageId };
|
||||
},
|
||||
textDelta(delta: string): ReedyEvent {
|
||||
return { type: 'text_delta', delta };
|
||||
},
|
||||
toolCall(args: {
|
||||
id: string;
|
||||
name: string;
|
||||
args: unknown;
|
||||
permission: PermissionLevel;
|
||||
}): ReedyEvent {
|
||||
return { type: 'tool_call', ...args };
|
||||
},
|
||||
toolResultOk(args: {
|
||||
id: string;
|
||||
name: string;
|
||||
result: unknown;
|
||||
durationMs: number;
|
||||
}): ReedyEvent {
|
||||
return { type: 'tool_result', ok: true, ...args };
|
||||
},
|
||||
toolResultErr(args: { id: string; name: string; error: ReedyToolError }): ReedyEvent {
|
||||
return { type: 'tool_result', ok: false, ...args };
|
||||
},
|
||||
citation(args: {
|
||||
cfi: string;
|
||||
sectionIndex: number;
|
||||
chapterTitle?: string;
|
||||
snippet: string;
|
||||
}): ReedyEvent {
|
||||
return { type: 'citation', ...args };
|
||||
},
|
||||
memoryWrite(scope: 'user' | 'book', key: string, summary: string): ReedyEvent {
|
||||
return { type: 'memory_write', scope, key, summary };
|
||||
},
|
||||
stepFinish(step: number, reason: 'stop' | 'tool-calls' | 'length'): ReedyEvent {
|
||||
return { type: 'step_finish', step, reason };
|
||||
},
|
||||
usage(promptTokens: number, completionTokens: number): ReedyEvent {
|
||||
return { type: 'usage', promptTokens, completionTokens };
|
||||
},
|
||||
error(kind: ReedyErrorKind, message: string, retryable: boolean): ReedyEvent {
|
||||
return { type: 'error', kind, message, retryable };
|
||||
},
|
||||
abort(partial: boolean): ReedyEvent {
|
||||
return { type: 'abort', partial };
|
||||
},
|
||||
done(output: ReedyTurnOutput): ReedyEvent {
|
||||
return { type: 'done', output };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,171 @@
|
||||
import { tool as vercelTool, type ToolSet } from 'ai';
|
||||
import { makeToolError, type ReedyToolError } from '../runtime/errors';
|
||||
import type { ReedyTool, ToolContext } from './types';
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 10_000;
|
||||
|
||||
/**
|
||||
* Registry that holds the runtime's tool catalog and adapts each tool to
|
||||
* the Vercel `ai`-SDK ToolSet `streamText` consumes (per Phase 2.3).
|
||||
*
|
||||
* The registry wraps every tool's `run()` with:
|
||||
* - Zod input validation → tool_invalid_args on mismatch
|
||||
* - Permission gate → tool_permission_denied on refusal
|
||||
* - Per-call timeout (default 10s) → tool_timeout
|
||||
* - AbortSignal propagation → tool_aborted on turn cancel
|
||||
* - Per-tool serialization → parallelSafe=false tools queue
|
||||
*
|
||||
* Errors bubble out as `ReedyToolError` instances so the streamLoop can
|
||||
* re-emit them as `{ tool_result, ok: false, error }` events without
|
||||
* lossy string-matching.
|
||||
*/
|
||||
export class ToolRegistry {
|
||||
private readonly tools = new Map<string, ReedyTool>();
|
||||
private readonly chains = new Map<string, Promise<unknown>>();
|
||||
|
||||
register<TArgs, TResult>(tool: ReedyTool<TArgs, TResult>): void {
|
||||
if (this.tools.has(tool.name)) {
|
||||
throw new Error(`ToolRegistry: tool "${tool.name}" already registered`);
|
||||
}
|
||||
this.tools.set(tool.name, tool as ReedyTool);
|
||||
}
|
||||
|
||||
unregister(name: string): boolean {
|
||||
return this.tools.delete(name);
|
||||
}
|
||||
|
||||
list(): ReedyTool[] {
|
||||
return [...this.tools.values()];
|
||||
}
|
||||
|
||||
get(name: string): ReedyTool | undefined {
|
||||
return this.tools.get(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapt every registered tool to the Vercel ToolSet shape. Each tool's
|
||||
* `execute` is wrapped per the policy above. The caller passes the
|
||||
* per-turn ToolContext once; every dispatch in that turn reuses it.
|
||||
*/
|
||||
toVercelToolSet(ctx: ToolContext): ToolSet {
|
||||
const set: Record<string, unknown> = {};
|
||||
for (const t of this.tools.values()) {
|
||||
set[t.name] = vercelTool({
|
||||
description: t.description,
|
||||
inputSchema: t.inputSchema,
|
||||
execute: async (rawArgs: unknown) => {
|
||||
return this.invoke(t.name, rawArgs, ctx);
|
||||
},
|
||||
});
|
||||
}
|
||||
return set as ToolSet;
|
||||
}
|
||||
|
||||
/**
|
||||
* Direct invoke path used by AgentRuntime (and tests). Returns the
|
||||
* tool's result on success; throws ReedyToolError on any failure.
|
||||
*/
|
||||
async invoke<TResult = unknown>(
|
||||
name: string,
|
||||
rawArgs: unknown,
|
||||
ctx: ToolContext,
|
||||
): Promise<TResult> {
|
||||
const t = this.tools.get(name);
|
||||
if (!t) {
|
||||
throw makeToolError('tool_unknown', name, `no tool registered under "${name}"`);
|
||||
}
|
||||
|
||||
// 1. Zod validation
|
||||
const parsed = t.inputSchema.safeParse(rawArgs);
|
||||
if (!parsed.success) {
|
||||
throw makeToolError(
|
||||
'tool_invalid_args',
|
||||
name,
|
||||
`arguments failed schema validation: ${parsed.error.message}`,
|
||||
parsed.error,
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Permission gate
|
||||
if (t.permission !== 'read') {
|
||||
let granted = false;
|
||||
try {
|
||||
granted = await ctx.requestPermission({
|
||||
tool: name,
|
||||
description: t.description,
|
||||
args: parsed.data,
|
||||
});
|
||||
} catch (err) {
|
||||
throw makeToolError('tool_permission_denied', name, 'permission request errored', err);
|
||||
}
|
||||
if (!granted) {
|
||||
throw makeToolError('tool_permission_denied', name, 'user denied permission');
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Serialize parallel-unsafe calls per-tool. We chain on whatever
|
||||
// in-flight invocation of THIS tool exists; parallelSafe tools
|
||||
// skip the chain and run immediately.
|
||||
if (!t.parallelSafe) {
|
||||
const prior = this.chains.get(name) ?? Promise.resolve();
|
||||
const next = prior.catch(() => undefined).then(() => runWithTimeout(t, parsed.data, ctx));
|
||||
this.chains.set(name, next);
|
||||
try {
|
||||
return (await next) as TResult;
|
||||
} finally {
|
||||
if (this.chains.get(name) === next) this.chains.delete(name);
|
||||
}
|
||||
}
|
||||
return runWithTimeout(t, parsed.data, ctx) as Promise<TResult>;
|
||||
}
|
||||
}
|
||||
|
||||
async function runWithTimeout<TArgs, TResult>(
|
||||
t: ReedyTool<TArgs, TResult>,
|
||||
args: TArgs,
|
||||
ctx: ToolContext,
|
||||
): Promise<TResult> {
|
||||
const timeoutMs = t.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||||
|
||||
// Compose: external abort (from turn cancel) + local timeout.
|
||||
const localController = new AbortController();
|
||||
const onParentAbort = (): void => localController.abort();
|
||||
if (ctx.signal.aborted) localController.abort();
|
||||
else ctx.signal.addEventListener('abort', onParentAbort, { once: true });
|
||||
const timer = setTimeout(() => localController.abort(), timeoutMs);
|
||||
|
||||
const innerCtx: ToolContext = { ...ctx, signal: localController.signal };
|
||||
|
||||
try {
|
||||
return await t.run(args, innerCtx);
|
||||
} catch (err) {
|
||||
// The tool may have thrown a domain-specific error; preserve cause
|
||||
// and classify as timeout/abort/runtime per signal state.
|
||||
if (localController.signal.aborted && !ctx.signal.aborted) {
|
||||
throw timeoutError(t.name, timeoutMs, err);
|
||||
}
|
||||
if (ctx.signal.aborted) {
|
||||
throw makeToolError('tool_aborted', t.name, 'turn aborted', err);
|
||||
}
|
||||
if (isReedyToolError(err)) throw err;
|
||||
throw makeToolError(
|
||||
'tool_runtime_error',
|
||||
t.name,
|
||||
err instanceof Error ? err.message : String(err),
|
||||
err,
|
||||
);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
ctx.signal.removeEventListener('abort', onParentAbort);
|
||||
}
|
||||
}
|
||||
|
||||
function timeoutError(name: string, ms: number, cause: unknown): ReedyToolError {
|
||||
return makeToolError('tool_timeout', name, `${name} exceeded ${ms}ms`, cause);
|
||||
}
|
||||
|
||||
function isReedyToolError(err: unknown): err is ReedyToolError {
|
||||
return (
|
||||
err instanceof Error && err.name === 'ReedyToolError' && 'kind' in err && 'toolName' in err
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { z } from 'zod';
|
||||
import type { PermissionLevel } from '../runtime/events';
|
||||
|
||||
export type { PermissionLevel };
|
||||
|
||||
/**
|
||||
* Per-call context handed to every tool. The runtime constructs one of
|
||||
* these per assistant turn and shares it across every tool dispatched in
|
||||
* that turn (per Phase 2.6 streamLoop).
|
||||
*/
|
||||
export interface ToolContext {
|
||||
/** The book the user has open. Tools may scope retrieval / writes to this. */
|
||||
bookHash: string;
|
||||
/** The Reedy session id (one session = one conversation thread). */
|
||||
sessionId: string;
|
||||
/** The assistant message currently being produced. */
|
||||
assistantMessageId: string;
|
||||
/** Abort signal for the whole turn; tools must wire it through. */
|
||||
signal: AbortSignal;
|
||||
/**
|
||||
* Prompt the user for permission to run a write/navigate tool. Resolves
|
||||
* true if approved, false if denied. Read-only tools never call this.
|
||||
*/
|
||||
requestPermission(args: { tool: string; description: string; args: unknown }): Promise<boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic tool contract every Reedy tool implements. Wraps the Vercel SDK's
|
||||
* tool({...}) shape with the metadata the runtime needs (permission,
|
||||
* parallelSafe, timeoutMs) so the streamLoop can enforce policy
|
||||
* uniformly without per-tool special-casing.
|
||||
*/
|
||||
export interface ReedyTool<TArgs = unknown, TResult = unknown> {
|
||||
/** Stable identifier the model sees in the tool catalog. */
|
||||
readonly name: string;
|
||||
/** One-line description shown to the model; used in the tool catalog layer. */
|
||||
readonly description: string;
|
||||
/** Permission tier the runtime gates the call behind. */
|
||||
readonly permission: PermissionLevel;
|
||||
/**
|
||||
* When false the registry/runtime serializes concurrent calls to this
|
||||
* tool within one step. Most read tools are parallel-safe; navigate /
|
||||
* write tools usually aren't.
|
||||
*/
|
||||
readonly parallelSafe: boolean;
|
||||
/** Per-call wall-clock budget; the registry enforces via AbortController. */
|
||||
readonly timeoutMs?: number;
|
||||
/** Zod schema for the tool's args. The registry validates before run(). */
|
||||
readonly inputSchema: z.ZodType<TArgs>;
|
||||
run(args: TArgs, ctx: ToolContext): Promise<TResult>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The shape the runtime needs back from registry.invoke() for each tool
|
||||
* call so it can emit the corresponding ReedyEvent.
|
||||
*/
|
||||
export interface ToolInvocation<TResult = unknown> {
|
||||
ok: true;
|
||||
result: TResult;
|
||||
durationMs: number;
|
||||
}
|
||||
Reference in New Issue
Block a user