diff --git a/apps/readest-app/src/__tests__/reedy/ToolRegistry.test.ts b/apps/readest-app/src/__tests__/reedy/ToolRegistry.test.ts new file mode 100644 index 00000000..03b0db46 --- /dev/null +++ b/apps/readest-app/src/__tests__/reedy/ToolRegistry.test.ts @@ -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 { + 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; + 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); + }); + }); +}); diff --git a/apps/readest-app/src/__tests__/reedy/registry.test.ts b/apps/readest-app/src/__tests__/reedy/registry.test.ts new file mode 100644 index 00000000..0e749a6a --- /dev/null +++ b/apps/readest-app/src/__tests__/reedy/registry.test.ts @@ -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 { + 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/); + }); +}); diff --git a/apps/readest-app/src/__tests__/reedy/runtime-events.test.ts b/apps/readest-app/src/__tests__/reedy/runtime-events.test.ts new file mode 100644 index 00000000..2c5a5683 --- /dev/null +++ b/apps/readest-app/src/__tests__/reedy/runtime-events.test.ts @@ -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); + } + }); +}); diff --git a/apps/readest-app/src/services/reedy/models/ChatModel.ts b/apps/readest-app/src/services/reedy/models/ChatModel.ts new file mode 100644 index 00000000..4d60480b --- /dev/null +++ b/apps/readest-app/src/services/reedy/models/ChatModel.ts @@ -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; +} diff --git a/apps/readest-app/src/services/reedy/models/registry.ts b/apps/readest-app/src/services/reedy/models/registry.ts new file mode 100644 index 00000000..bf24de13 --- /dev/null +++ b/apps/readest-app/src/services/reedy/models/registry.ts @@ -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'; + } +} diff --git a/apps/readest-app/src/services/reedy/runtime/errors.ts b/apps/readest-app/src/services/reedy/runtime/errors.ts new file mode 100644 index 00000000..06227e10 --- /dev/null +++ b/apps/readest-app/src/services/reedy/runtime/errors.ts @@ -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 = { + 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 }); +} diff --git a/apps/readest-app/src/services/reedy/runtime/events.ts b/apps/readest-app/src/services/reedy/runtime/events.ts new file mode 100644 index 00000000..8961aa94 --- /dev/null +++ b/apps/readest-app/src/services/reedy/runtime/events.ts @@ -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 }; + }, +}; diff --git a/apps/readest-app/src/services/reedy/tools/ToolRegistry.ts b/apps/readest-app/src/services/reedy/tools/ToolRegistry.ts new file mode 100644 index 00000000..7af3ca3f --- /dev/null +++ b/apps/readest-app/src/services/reedy/tools/ToolRegistry.ts @@ -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(); + private readonly chains = new Map>(); + + register(tool: ReedyTool): 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 = {}; + 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( + name: string, + rawArgs: unknown, + ctx: ToolContext, + ): Promise { + 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; + } +} + +async function runWithTimeout( + t: ReedyTool, + args: TArgs, + ctx: ToolContext, +): Promise { + 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 + ); +} diff --git a/apps/readest-app/src/services/reedy/tools/types.ts b/apps/readest-app/src/services/reedy/tools/types.ts new file mode 100644 index 00000000..0b4b86e6 --- /dev/null +++ b/apps/readest-app/src/services/reedy/tools/types.ts @@ -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; +} + +/** + * 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 { + /** 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; + run(args: TArgs, ctx: ToolContext): Promise; +} + +/** + * The shape the runtime needs back from registry.invoke() for each tool + * call so it can emit the corresponding ReedyEvent. + */ +export interface ToolInvocation { + ok: true; + result: TResult; + durationMs: number; +}