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:
Huang Xin
2026-05-26 08:30:34 +08:00
committed by GitHub
parent 6bc4a96b99
commit 4d96c0d54a
9 changed files with 1147 additions and 0 deletions
@@ -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;
}