UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

409 lines (408 loc) • 17.8 kB
import { createHash } from "node:crypto"; import { Type } from "typebox"; import { uuidv7 } from "../engine/session/uuid.js"; import { defineTool, errorResult } from "./tools.js"; import { deliverEngineNotice } from "./types.js"; import { TOOL_SEARCH_NAME } from "./runner/tool-disclosure.js"; import { toolFace } from "./tool-catalog-entries.js"; import { thrownText } from "./thrown-value.js"; export const TOOL_RESULT_REF_CONFLICT_CODE = "tool_result.ref_conflict"; export class ToolResultRefConflictError extends Error { ref; stored; incoming; code = TOOL_RESULT_REF_CONFLICT_CODE; constructor(ref, stored, incoming) { super(`tool-result store: ref ${JSON.stringify(ref)} already belongs to a different origin ` + `(stored ${describeProvenance(stored)}, offered ${describeProvenance(incoming)}) — refusing to store under it`); this.ref = ref; this.stored = stored; this.incoming = incoming; this.name = "ToolResultRefConflictError"; } } function describeProvenance(p) { return p.taskId === undefined ? `session=${p.sessionId}` : `session=${p.sessionId} task=${p.taskId}`; } export function assertToolResultProvenanceMatch(ref, stored, incoming) { if (stored === undefined || incoming === undefined) return; if (stored.sessionId === incoming.sessionId && stored.taskId === incoming.taskId) return; throw new ToolResultRefConflictError(ref, stored, incoming); } export function normalizeToolResultProvenance(p) { return p.taskId === undefined ? { sessionId: p.sessionId } : { sessionId: p.sessionId, taskId: p.taskId }; } export function assertSafeToolResultRef(ref) { const bad = ref === "" || ref === "." || ref === ".." || ref.includes("/") || ref.includes("\\") || [...ref].some((ch) => ch.charCodeAt(0) < 0x20 || ch.charCodeAt(0) === 0x7f); if (bad) throw new Error(`tool-result store: unsafe ref ${JSON.stringify(ref)}`); } const NATIVE_REF_CHARSET = /^[A-Za-z0-9_.-]+$/; const MAX_REF_SEGMENT_CHARS = 128; const REF_SEGMENT_SEPARATOR = "~"; const FOLDED_SEGMENT_PREFIX = "fold."; export function buildToolResultRef(sessionId, toolCallId, ...extraSegments) { return [`tr_${refSegment(sessionId)}`, refSegment(toolCallId), ...extraSegments.map(refSegment)].join(REF_SEGMENT_SEPARATOR); } function refSegment(raw) { if (raw.length <= MAX_REF_SEGMENT_CHARS && NATIVE_REF_CHARSET.test(raw) && !raw.startsWith(FOLDED_SEGMENT_PREFIX)) return raw; const base = raw.replace(/[^A-Za-z0-9_.-]/g, "-").slice(0, 32); return `${FOLDED_SEGMENT_PREFIX}${base || "x"}-${digestOfString(raw)}`; } export const MAX_MINTED_TOOL_RESULT_REF_CHARS = 3 + 4 * MAX_REF_SEGMENT_CHARS + 3; export function toolResultContentSegment(text) { return `s${digestOfString(text).slice(0, 32)}`; } function digestOfString(s) { return createHash("sha256").update(Buffer.from(s, "utf16le")).digest("hex"); } export class InMemoryToolResultStore { opts; retention = "none"; map = new Map(); totalChars = 0; constructor(opts) { this.opts = opts; const cap = opts?.maxTotalChars; if (cap !== undefined && Number.isNaN(cap)) { throw new Error("InMemoryToolResultStore: maxTotalChars must be a number (got NaN) — omit it for an unbounded store"); } } put(ref, content, provenance) { assertSafeToolResultRef(ref); const existing = this.map.get(ref); if (existing !== undefined) { assertToolResultProvenanceMatch(ref, existing.provenance, provenance); return; } this.map.set(ref, provenance === undefined ? { content } : { content, provenance: normalizeToolResultProvenance(provenance) }); this.totalChars += content.length; const cap = this.opts?.maxTotalChars; if (cap !== undefined && Number.isFinite(cap)) { for (const [oldRef, oldEntry] of this.map) { if (this.totalChars <= cap || oldRef === ref) break; this.map.delete(oldRef); this.totalChars -= oldEntry.content.length; } } } ownerOf(ref) { return this.map.get(ref)?.provenance; } deleteBySession(sessionId) { let deleted = 0; let unattributable = 0; for (const [ref, entry] of this.map) { if (entry.provenance === undefined) { unattributable++; continue; } if (entry.provenance.sessionId !== sessionId) continue; this.map.delete(ref); this.totalChars -= entry.content.length; deleted++; } return { deleted, unattributable }; } isEmpty() { return this.map.size === 0; } get(ref, opts) { const full = this.map.get(ref)?.content; if (full === undefined) return undefined; const offset = Math.min(full.length, Math.max(0, intOr(opts?.offset, 0))); const limit = opts?.limit != null ? Math.max(0, intOr(opts.limit, 0)) : undefined; const content = limit != null ? full.slice(offset, offset + limit) : full.slice(offset); return { content, offset, totalChars: full.length }; } } export class RunnerSharedToolResultStore extends InMemoryToolResultStore { } export class ScopedToolResultStore { inner; scope; localPuts = 0; volatileBacking; constructor(inner, scope) { this.inner = inner; this.scope = scope; this.volatileBacking = inner instanceof InMemoryToolResultStore; } key(ref) { const scope = encodeScopeSegment(this.scope); return `${scope.length}:${scope}:${ref}`; } put(ref, content, provenance) { assertSafeToolResultRef(ref); this.localPuts++; return this.inner.put(this.key(ref), content, provenance); } get(ref, opts) { return this.inner.get(this.key(ref), opts); } ownerOf(ref) { return this.inner.ownerOf?.(this.key(ref)); } isEmpty() { return this.localPuts === 0; } } function encodeScopeSegment(scope) { let out = ""; for (let i = 0; i < scope.length; i++) { const ch = scope[i]; out += /[A-Za-z0-9]/.test(ch) ? ch : `%${ch.charCodeAt(0).toString(16).padStart(4, "0")}`; } return out; } export function isVolatileOffloadStore(store) { if (store instanceof ScopedToolResultStore) return store.volatileBacking; return store instanceof InMemoryToolResultStore; } export const OFFLOAD_TOOL_NAME = "ReadToolResult"; export function createOffloadPersist(store, sessionId, onNotice) { const provenance = toolResultProvenanceOf(sessionId); return (toolCallId, fullText) => { const ref = buildToolResultRef(sessionId, toolCallId, toolResultContentSegment(fullText)); const reportFailedWrite = (err) => { const cause = thrownText(err); const message = `tool-result offload: persisting ref "${ref}" failed (${cause}) — this write attempt stored nothing; ` + `unless an earlier attempt already stored this ref, the inline preview is all that survived. ` + `A later re-clear of the same result retries the write.`; deliverEngineNotice(onNotice, { code: "tool_result.offload_put_failed", message, detail: { ref, sessionId, cause, occurrenceId: uuidv7() } }); }; try { void Promise.resolve(store.put(ref, fullText, provenance)).catch(reportFailedWrite); } catch (err) { reportFailedWrite(err); } return ref; }; } export function toolResultProvenanceOf(sessionId, taskId) { return taskId === undefined ? { sessionId } : { sessionId, taskId }; } export function offloadPagebackHint(ref, form, reachableTools) { const activate = reachableTools !== undefined && !reachableTools.has(OFFLOAD_TOOL_NAME) ? `${OFFLOAD_TOOL_NAME} is not active yet — call ${TOOL_SEARCH_NAME} with {"query":"select:${OFFLOAD_TOOL_NAME}"} to activate it, then ` : ""; const core = `${activate === "" && form === "preview" ? "Call" : `${activate}call`} ${OFFLOAD_TOOL_NAME} with ref`; return form === "preview" ? `${core}="${ref}" (offset, limit) to read more.` : `full text persisted; ${core} "${ref}" to read it back`; } export const PERSISTED_OUTPUT_PREFIX = "<persisted-output ref="; export const DEFAULT_TOOL_RESULT_THRESHOLD_CHARS = 20_000; const PREVIEW_HEAD_CHARS = 1_000; const PREVIEW_TAIL_CHARS = 1_000; const READ_DEFAULT_LIMIT = 4_000; const READ_MAX_LIMIT = 20_000; function intOr(x, fallback) { return Number.isFinite(x) ? Math.floor(x) : fallback; } function totalTextChars(content) { let n = 0; for (const b of content) if (b.type === "text" && typeof b.text === "string") n += b.text.length; return n; } export function buildPreview(full, ref, sizes, reachableTools) { const total = full.length; const headChars = sizes?.head ?? PREVIEW_HEAD_CHARS; const tailChars = sizes?.tail ?? PREVIEW_TAIL_CHARS; const head = full.slice(0, headChars); const tailStart = Math.max(headChars, total - tailChars); const tail = full.slice(tailStart); return (`${PERSISTED_OUTPUT_PREFIX}"${ref}" chars="${total}">\n` + `${head}\n` + `…[truncated — ${total} chars total, retained in this run's tool-result store; a user-facing read face is a deployment fact. ` + `${offloadPagebackHint(ref, "preview", reachableTools)}]\n` + `${tail}\n` + `</persisted-output>`); } export function withToolResultOffload(tool, store, thresholdChars, sessionId, reachableTools) { if (!(Number.isFinite(thresholdChars) && thresholdChars > 0)) return tool; const provenance = toolResultProvenanceOf(sessionId); const wrappedExecute = async (toolCallId, params, signal, onUpdate) => { const res = await tool.execute(toolCallId, params, signal, onUpdate); const offloadedDetails = res.details === undefined ? undefined : await offloadOversizedDetailStrings(res.details, store, thresholdChars, sessionId, toolCallId, provenance); const withDetails = (r) => offloadedDetails === undefined || offloadedDetails.value === res.details ? r : { ...r, details: offloadedDetails.value }; if (totalTextChars(res.content) <= thresholdChars) return withDetails(res); const full = res.content .filter((b) => b.type === "text") .map((b) => b.text) .join("\n"); if (full.length <= PREVIEW_HEAD_CHARS + PREVIEW_TAIL_CHARS) return withDetails(res); const ref = buildToolResultRef(sessionId, toolCallId, toolResultContentSegment(full)); try { await store.put(ref, full, provenance); } catch { return withDetails(res); } const images = res.content.filter((b) => b.type !== "text"); return withDetails({ ...res, content: [{ type: "text", text: buildPreview(full, ref, undefined, reachableTools?.()) }, ...images] }); }; return { ...tool, execute: wrappedExecute }; } const OFFLOADED_DETAIL_HEAD_CHARS = 2_000; const OFFLOADED_DETAIL_NOTICE_ALLOWANCE_CHARS = MAX_MINTED_TOOL_RESULT_REF_CHARS + 200; export const OFFLOADED_DETAIL_NOTICE_PREFIX = "…[offloaded — "; export function isOffloadedDetailReplacement(s) { return s.includes(`\n${OFFLOADED_DETAIL_NOTICE_PREFIX}`); } async function offloadOversizedDetailStrings(details, store, thresholdChars, sessionId, toolCallId, provenance) { const isPlainObject = (v) => { if (typeof v !== "object" || v === null) return false; const p = Object.getPrototypeOf(v); return p === Object.prototype || p === null; }; const onStack = new Set(); const pending = []; const rebuilt = new Set(); const replace = (full, path) => { const detailRef = buildToolResultRef(sessionId, toolCallId, path, toolResultContentSegment(full)); let done; try { done = Promise.resolve(store.put(detailRef, full, provenance)); } catch (err) { done = Promise.reject(err); } const marker = {}; pending.push({ marker, done, render: (stored) => `${full.slice(0, OFFLOADED_DETAIL_HEAD_CHARS)}\n${OFFLOADED_DETAIL_NOTICE_PREFIX}${full.length} chars total; ` + (stored ? `ref "${detailRef}"; the remainder is retained in this run's tool-result store and reads back through the deployment's tool-results face]` : `persisting the remainder to this run's tool-result store failed or went unconfirmed, so no ref is offered — the text above is all this field carries]`), }); return marker; }; const memo = new Map(); const walk = (v, segs) => { if (typeof v === "string") return v.length > thresholdChars && v.length > OFFLOADED_DETAIL_HEAD_CHARS + OFFLOADED_DETAIL_NOTICE_ALLOWANCE_CHARS ? replace(v, JSON.stringify(segs)) : v; if (Array.isArray(v)) { if (onStack.has(v)) return v; const done = memo.get(v); if (done !== undefined) return done; onStack.add(v); let changed = false; const len = v.length; const next = new Array(len); for (let i = 0; i < len; i++) { if (!(i in v)) continue; const item = v[i]; const w = walk(item, [...segs, i]); if (w !== item) changed = true; Object.defineProperty(next, i, { value: w, enumerable: true, writable: true, configurable: true }); } onStack.delete(v); const result = changed ? next : v; if (changed) rebuilt.add(next); memo.set(v, result); return result; } if (isPlainObject(v)) { if (onStack.has(v)) return v; const done = memo.get(v); if (done !== undefined) return done; onStack.add(v); let changed = false; const next = {}; for (const [k, item] of Object.entries(v)) { const w = walk(item, [...segs, k]); if (w !== item) changed = true; Object.defineProperty(next, k, { value: w, enumerable: true, writable: true, configurable: true }); } onStack.delete(v); const result = changed ? next : v; if (changed) rebuilt.add(next); memo.set(v, result); return result; } return v; }; const value = walk(details, []); const settled = await Promise.allSettled(pending.map((p) => p.done)); const notices = new Map(); pending.forEach((p, i) => { notices.set(p.marker, p.render(settled[i].status === "fulfilled")); }); const seen = new Set(); const finalize = (node) => { if (typeof node !== "object" || node === null) return node; const notice = notices.get(node); if (notice !== undefined) return notice; if (!rebuilt.has(node) || seen.has(node)) return node; seen.add(node); if (Array.isArray(node)) { for (let i = 0; i < node.length; i++) { if (!Object.hasOwn(node, i)) continue; Object.defineProperty(node, i, { value: finalize(node[i]), enumerable: true, writable: true, configurable: true }); } return node; } const obj = node; for (const k of Object.keys(obj)) Object.defineProperty(obj, k, { value: finalize(obj[k]), enumerable: true, writable: true, configurable: true }); return node; }; return { value: finalize(value) }; } export function createReadToolResultTool(store) { return defineTool({ name: OFFLOAD_TOOL_NAME, ...toolFace("read-tool-result"), description: `Read the full content of a tool result that was too large and persisted (you saw it as ` + `<persisted-output ref="..."> with a preview). Pass the ref; use offset/limit to page through it.`, parameters: Type.Object({ ref: Type.String({ description: "The ref from the <persisted-output> tag." }), offset: Type.Optional(Type.Number({ description: "Char offset to start at (default 0)." })), limit: Type.Optional(Type.Number({ description: `Max chars to return (default ${READ_DEFAULT_LIMIT}, capped ${READ_MAX_LIMIT}).` })), }), execute: async (args) => { const { ref, offset, limit } = args; const off = Math.max(0, intOr(offset, 0)); const lim = Math.min(READ_MAX_LIMIT, Math.max(1, intOr(limit, READ_DEFAULT_LIMIT))); const slice = await store.get(ref, { offset: off, limit: lim }); if (!slice) { return errorResult(`No persisted output for ref "${ref}" — it may have expired, or this process has no durable tool-result store (the preview above is all that remains).`); } const end = slice.offset + slice.content.length; const more = end < slice.totalChars ? `\n…[${slice.totalChars - end} more chars — call again with offset=${end}.]` : ""; return `[ref=${ref} · chars ${slice.offset}–${end} of ${slice.totalChars}]\n${slice.content}${more}`; }, }); }