/** * Shared Zod schemas + helpers for the memory tool family. * * The tool schemas deliberately do NOT expose `agent_id` or `user_id`. Scope * is captured in a closure at tool construction time by {@link buildMemoryTools}, * so even a hallucinated tool call with extra fields cannot influence it. */ import { z } from 'zod'; import { DEFAULT_MAX_INJECTED_CHARS, MEMORY_PATH_PREFIX, } from '@/memory/constants'; import type { MemoryAppendInput, MemoryBackend, MemoryEntry, MemoryScope, } from '@/memory/types'; export const MemorySearchSchema = z.object({ query: z.string().describe('Natural-language query to search memory for'), maxResults: z .number() .int() .min(1) .max(50) .optional() .describe('Maximum number of results to return (default 10)'), minScore: z .number() .min(0) .max(1) .optional() .describe('Minimum hybrid score (0..1) below which results are dropped'), }); export const MemoryGetSchema = z.object({ path: z .string() .describe( 'Memory path, e.g. "memory/decisions.md". Must start with "memory/"' ), from: z .number() .int() .min(1) .optional() .describe('Starting line number (1-indexed)'), lines: z .number() .int() .min(1) .optional() .describe('Number of lines to read starting at `from`'), }); export const MemoryAppendSchema = z.object({ path: z .string() .describe( 'Memory path to append to (e.g. "memory/decisions.md"). Must start with "memory/"' ), content: z .string() .min(1) .describe( "Note content in the agent's own voice. Markdown allowed. Each call appends an immutable entry." ), }); export type MemorySearchArgs = z.infer; export type MemoryGetArgs = z.infer; export type MemoryAppendArgs = z.infer; /** Shape returned by `memory_search` — serialised as the tool result string. */ export interface MemorySearchToolResult { results: Array< Pick & { citation?: string; } >; disabled?: boolean; unavailable?: boolean; error?: string; warning?: string; action?: string; } /** Shape returned by `memory_get`. */ export interface MemoryGetToolResult { path: string; text: string; disabled?: boolean; error?: string; } export function buildMemorySearchUnavailableResult( error: string | undefined ): MemorySearchToolResult { const reason = (error ?? 'memory search unavailable').trim() || 'memory search unavailable'; const isQuota = /insufficient_quota|quota|429/i.test(reason); return { results: [], disabled: true, unavailable: true, error: reason, warning: isQuota ? 'Memory search is unavailable because the embedding provider quota is exhausted.' : 'Memory search is unavailable due to an embedding/provider error.', action: isQuota ? 'Top up or switch embedding provider, then retry memory_search.' : 'Check embedding provider configuration and retry memory_search.', }; } /** * Clamp a ranked result list to a total character budget. * Ported from upstream `tools.citations.ts::clampResultsByInjectedChars`. */ export function clampResultsByInjectedChars( results: T[], maxInjectedChars: number = DEFAULT_MAX_INJECTED_CHARS ): T[] { const out: T[] = []; let total = 0; for (const result of results) { const size = result.content.length ?? 0; if (total + size > maxInjectedChars && out.length > 0) break; out.push(result); total += size; } return out; } export interface MemoryToolBinding { backend: MemoryBackend; scope: MemoryScope; maxInjectedChars?: number; /** Phase 2 — per-call search options propagated into backend.search. */ searchOptions?: { mmr?: { enabled?: boolean; lambda?: number }; temporalDecay?: { enabled?: boolean; halfLifeDays?: number }; citations?: 'on' | 'off' | 'auto'; }; /** Phase 2 — optional best-effort recall recorder. */ recallTracker?: { record(params: { agentId: string; query: string; hits: Array<{ id: string; path: string; score: number }>; }): Promise; }; } export function assertAppendAllowed(path: string): void { if (!path || !path.startsWith(MEMORY_PATH_PREFIX)) { throw new Error( `memory_append path must start with "${MEMORY_PATH_PREFIX}"` ); } } /** Normalise an append call into the backend input shape. */ export function toAppendInput(args: MemoryAppendArgs): MemoryAppendInput { assertAppendAllowed(args.path); return { path: args.path, content: args.content }; }