/** * JSON-RPC method dispatcher for the bridge. * * Given a live `MemoryCore`, returns a function that maps a JSON-RPC * method + params to a promise resolving to the method's result (or * rejecting with a `MemosError`). The dispatcher is transport-agnostic; * stdio and TCP entry points both call into it. * * Routing follows the registry in `agent-contract/jsonrpc.ts` (`RPC_METHODS`). * Unknown methods raise `unknown_method`; malformed params raise * `invalid_argument`. Every error carries the stable `ErrorCode` so * non-TS adapters can handle them programmatically. */ import { MemosError, type ErrorCode } from "../agent-contract/errors.js"; import type { MemoryCore } from "../agent-contract/memory-core.js"; import { RPC_METHODS, isRpcMethodName, type RpcMethodName, } from "../agent-contract/jsonrpc.js"; import type { AgentKind, EpisodeId, FeedbackDTO, RetrievalQueryDTO, SessionId, SkillDTO, SkillId, SubagentOutcomeDTO, ToolOutcomeDTO, TurnInputDTO, TurnResultDTO, RuntimeNamespace, } from "../agent-contract/dto.js"; export interface DispatcherOptions { /** Strict schema validation for `params`. Off by default (fast path). */ strict?: boolean; } export interface DispatchContext { /** Connection-scoped unique id. Used for cancellation + log correlation. */ connectionId?: string; } export type Dispatcher = ( method: string, params: unknown, ctx?: DispatchContext, ) => Promise; // ─── Param helpers ────────────────────────────────────────────────────────── function asRecord(p: unknown, method: RpcMethodName): Record { if (p == null) return {}; if (typeof p !== "object" || Array.isArray(p)) { throw new MemosError( "invalid_argument", `${method}: params must be an object, got ${typeof p}`, ); } return p as Record; } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function namespaceParam(p: Record): RuntimeNamespace | undefined { return isRecord(p.namespace) ? (p.namespace as unknown as RuntimeNamespace) : undefined; } function requireString( obj: Record, key: string, method: RpcMethodName, ): string { const v = obj[key]; if (typeof v !== "string" || v.length === 0) { throw new MemosError( "invalid_argument", `${method}: '${key}' must be a non-empty string`, ); } return v; } // ─── Dispatcher factory ───────────────────────────────────────────────────── export function makeDispatcher( core: MemoryCore, options: DispatcherOptions = {}, ): Dispatcher { const strict = options.strict ?? false; return async function dispatch( method: string, params: unknown, _ctx?: DispatchContext, ) { if (!isRpcMethodName(method)) { throw new MemosError("unknown_method", `unknown JSON-RPC method: ${method}`); } switch (method) { // ── lifecycle ── // CORE_INIT is retained for backward compatibility with any // out-of-tree adapter that might still send it, but it is // **deprecated** for in-tree use. The bridge always calls // `core.init()` in-process during `bridge.cts::main()` — see // the ordering invariant there (also enforced by // `tests/unit/bridge/bridge-startup-ordering.test.ts`). No // in-tree stdio adapter (OpenClaw, Hermes Python) sends this // RPC. Issue #1747 flagged it as dead code; we keep the // dispatch branch to avoid a wire-contract break and revisit // removal once telemetry confirms zero callers. case RPC_METHODS.CORE_INIT: await core.init(); return { ok: true }; case RPC_METHODS.CORE_SHUTDOWN: await core.shutdown(); return { ok: true }; case RPC_METHODS.CORE_HEALTH: return await core.health(); // ── session / episode ── case RPC_METHODS.SESSION_OPEN: { const p = asRecord(params, method); const agent = requireString(p, "agent", method) as AgentKind; const sessionId = typeof p.sessionId === "string" && p.sessionId.length > 0 ? (p.sessionId as SessionId) : undefined; const meta = isRecord(p.meta) ? p.meta : undefined; const out = await core.openSession({ agent, sessionId, meta, namespace: namespaceParam(p) }); return { sessionId: out }; } case RPC_METHODS.SESSION_CLOSE: { const p = asRecord(params, method); await core.closeSession(requireString(p, "sessionId", method) as SessionId); return { ok: true }; } case RPC_METHODS.EPISODE_OPEN: { const p = asRecord(params, method); const sessionId = requireString(p, "sessionId", method) as SessionId; const episodeId = typeof p.episodeId === "string" ? (p.episodeId as EpisodeId) : undefined; const userMessage = typeof p.userMessage === "string" ? p.userMessage : undefined; const out = await core.openEpisode({ sessionId, episodeId, userMessage }); return { episodeId: out }; } case RPC_METHODS.EPISODE_CLOSE: { const p = asRecord(params, method); await core.closeEpisode(requireString(p, "episodeId", method) as EpisodeId); return { ok: true }; } // ── turn lifecycle ── case RPC_METHODS.TURN_START: { const p = asRecord(params, method); if (strict) validateTurnInput(p); return await core.onTurnStart(p as unknown as TurnInputDTO); } case RPC_METHODS.TURN_END: { const p = asRecord(params, method); if (strict) validateTurnResult(p); return await core.onTurnEnd(p as unknown as TurnResultDTO); } case RPC_METHODS.FEEDBACK_SUBMIT: { const p = asRecord(params, method); const fb: Omit & { ts?: number } = { episodeId: p.episodeId as EpisodeId | undefined, traceId: p.traceId as string | undefined, channel: p.channel as FeedbackDTO["channel"], polarity: p.polarity as FeedbackDTO["polarity"], magnitude: typeof p.magnitude === "number" ? p.magnitude : 0, rationale: typeof p.rationale === "string" ? p.rationale : undefined, raw: p.raw, ts: typeof p.ts === "number" ? p.ts : undefined, }; return await core.submitFeedback(fb); } // ── memory queries ── case RPC_METHODS.MEMORY_SEARCH: { const p = asRecord(params, method); const q: RetrievalQueryDTO = { agent: (typeof p.agent === "string" ? p.agent : "openclaw") as AgentKind, namespace: namespaceParam(p), sessionId: (p.sessionId as SessionId | undefined) ?? undefined, episodeId: (p.episodeId as EpisodeId | undefined) ?? undefined, query: requireString(p, "query", method), filters: (p.filters as Record | undefined) ?? undefined, topK: (p.topK as RetrievalQueryDTO["topK"]) ?? undefined, }; return await core.searchMemory(q); } case RPC_METHODS.MEMORY_GET_TRACE: { const p = asRecord(params, method); const id = requireString(p, "id", method); const ns = namespaceParam(p); return ns ? await core.getTrace(id, ns) : await core.getTrace(id); } case RPC_METHODS.MEMORY_GET_POLICY: { const p = asRecord(params, method); const id = requireString(p, "id", method); const ns = namespaceParam(p); return ns ? await core.getPolicy(id, ns) : await core.getPolicy(id); } case RPC_METHODS.MEMORY_GET_WORLD: { const p = asRecord(params, method); const id = requireString(p, "id", method); const ns = namespaceParam(p); return ns ? await core.getWorldModel(id, ns) : await core.getWorldModel(id); } case RPC_METHODS.MEMORY_LIST_EPISODES: { const p = asRecord(params, method); const out = await core.listEpisodes({ sessionId: (p.sessionId as SessionId | undefined) ?? undefined, limit: typeof p.limit === "number" ? p.limit : undefined, offset: typeof p.offset === "number" ? p.offset : undefined, }); return { episodeIds: out }; } case RPC_METHODS.MEMORY_TIMELINE: { const p = asRecord(params, method); const out = await core.timeline({ episodeId: requireString(p, "episodeId", method) as EpisodeId, namespace: namespaceParam(p), }); return { traces: out }; } case RPC_METHODS.MEMORY_LIST_TRACES: { const p = asRecord(params, method); const out = await core.listTraces({ limit: typeof p.limit === "number" ? p.limit : undefined, offset: typeof p.offset === "number" ? p.offset : undefined, sessionId: (p.sessionId as SessionId | undefined) ?? undefined, q: typeof p.q === "string" ? p.q : undefined, }); return { traces: out }; } case RPC_METHODS.MEMORY_LIST_WORLDS: { const p = asRecord(params, method); const input: Parameters[0] = { limit: typeof p.limit === "number" ? p.limit : undefined, offset: typeof p.offset === "number" ? p.offset : undefined, q: typeof p.q === "string" ? p.q : undefined, }; const ns = namespaceParam(p); if (ns) input!.namespace = ns; const out = await core.listWorldModels(input); return { worldModels: out }; } // ── skills ── case RPC_METHODS.SKILL_LIST: { const p = asRecord(params, method); const input: Parameters[0] = { status: (p.status as SkillDTO["status"] | undefined) ?? undefined, limit: typeof p.limit === "number" ? p.limit : undefined, }; const ns = namespaceParam(p); if (ns) input!.namespace = ns; const out = await core.listSkills(input); return { skills: out }; } case RPC_METHODS.SKILL_GET: { const p = asRecord(params, method); const opts: Parameters[1] = {}; const hasOptions = p.recordTrial !== undefined || p.sessionId !== undefined || p.episodeId !== undefined || p.traceId !== undefined || p.turnId !== undefined || p.toolCallId !== undefined || p.namespace !== undefined; if (hasOptions) { opts.recordUse = true; opts.recordTrial = p.recordTrial !== false; } if (typeof p.sessionId === "string") opts.sessionId = p.sessionId as SessionId; if (typeof p.episodeId === "string") opts.episodeId = p.episodeId as EpisodeId; if (typeof p.traceId === "string") opts.traceId = p.traceId; if (typeof p.turnId === "number") opts.turnId = p.turnId; if (typeof p.toolCallId === "string") opts.toolCallId = p.toolCallId; const ns = namespaceParam(p); if (ns) opts.namespace = ns; const id = requireString(p, "id", method) as SkillId; return hasOptions ? await core.getSkill(id, opts) : await core.getSkill(id); } case RPC_METHODS.SKILL_ARCHIVE: { const p = asRecord(params, method); await core.archiveSkill( requireString(p, "id", method) as SkillId, typeof p.reason === "string" ? p.reason : undefined, ); return { ok: true }; } // ── retrieval ── case RPC_METHODS.RETRIEVAL_QUERY: { // Delegated to `memory.search` for V1 — the dedicated triggers // (tool_driven, skill_invoke, sub_agent, decision_repair) will // land in V1.1 once adapters grow explicit entry points. const p = asRecord(params, method); return await core.searchMemory({ agent: (typeof p.agent === "string" ? p.agent : "openclaw") as AgentKind, query: requireString(p, "query", method), }); } // ── subagents ── case RPC_METHODS.SUBAGENT_RECORD: { const p = asRecord(params, method); const input: SubagentOutcomeDTO = { agent: (typeof p.agent === "string" ? p.agent : "hermes") as AgentKind, sessionId: requireString(p, "sessionId", method) as SessionId, episodeId: typeof p.episodeId === "string" && p.episodeId.length > 0 ? (p.episodeId as SubagentOutcomeDTO["episodeId"]) : undefined, childSessionId: typeof p.childSessionId === "string" && p.childSessionId.length > 0 ? (p.childSessionId as SessionId) : null, task: requireString(p, "task", method), result: typeof p.result === "string" ? p.result : "", toolCalls: Array.isArray(p.toolCalls) ? (p.toolCalls as SubagentOutcomeDTO["toolCalls"]) : undefined, outcome: typeof p.outcome === "string" ? p.outcome as SubagentOutcomeDTO["outcome"] : undefined, error: typeof p.error === "string" ? p.error : undefined, ts: typeof p.ts === "number" ? p.ts : undefined, meta: p.meta && typeof p.meta === "object" && !Array.isArray(p.meta) ? p.meta as Record : undefined, }; return await core.recordSubagentOutcome(input); } // ── tool-outcome ── // Not registered as a public RPC yet — the core exposes the method // but we route it via a notification on the events stream instead. // Leaving a branch here would be dead code; we intentionally drop. // ── config / hub ── case RPC_METHODS.CONFIG_GET: case RPC_METHODS.CONFIG_PATCH: case RPC_METHODS.HUB_STATUS: case RPC_METHODS.HUB_PUBLISH: case RPC_METHODS.HUB_PULL: throw new MemosError( "unknown_method", `${method}: not implemented yet in V1`, ); // ── logs + events ── case RPC_METHODS.LOGS_TAIL: case RPC_METHODS.LOGS_FORWARD: case RPC_METHODS.EVENTS_SUBSCRIBE: case RPC_METHODS.EVENTS_UNSUBSCRIBE: // Handled by the transport layer (SSE / notification channels). throw new MemosError( "protocol_error", `${method}: must be handled by the transport, not the dispatcher`, ); default: throw new MemosError("unknown_method", `unsupported method: ${method}`); } }; } // ─── Validators (strict mode) ─────────────────────────────────────────────── function validateTurnInput(p: Record): void { requireKey(p, "agent", "string", "turn.start"); requireKey(p, "sessionId", "string", "turn.start"); requireKey(p, "userText", "string", "turn.start"); requireKey(p, "ts", "number", "turn.start"); if (p.turnKey !== undefined && typeof p.turnKey !== "string") { throw new MemosError( "invalid_argument", "turn.start: optional 'turnKey' must be a string", ); } if ( p.deadlineAt !== undefined && (typeof p.deadlineAt !== "number" || !Number.isFinite(p.deadlineAt)) ) { throw new MemosError( "invalid_argument", "turn.start: optional 'deadlineAt' must be a finite number", ); } } function validateTurnResult(p: Record): void { requireKey(p, "agent", "string", "turn.end"); requireKey(p, "sessionId", "string", "turn.end"); requireKey(p, "episodeId", "string", "turn.end"); requireKey(p, "agentText", "string", "turn.end"); if (!Array.isArray(p.toolCalls)) { throw new MemosError( "invalid_argument", "turn.end: 'toolCalls' must be an array", ); } requireKey(p, "ts", "number", "turn.end"); } function requireKey( p: Record, key: string, type: "string" | "number", method: string, ): void { const v = p[key]; if (typeof v !== type) { throw new MemosError( "invalid_argument", `${method}: '${key}' must be a ${type}`, ); } } // ─── Error code helpers (re-exported for transports) ──────────────────────── export function errorCodeOf(err: unknown): ErrorCode { if (err instanceof MemosError) return err.code; return "internal"; } /** ToolOutcomeDTO is used elsewhere but referenced here for completeness. */ export type { ToolOutcomeDTO };