UNPKG

@kontextmind/kxm

Version:

KXM local-first multi-agent orchestration and operator dashboard

784 lines (721 loc) • 27.7 kB
import { createHash } from "node:crypto"; import { hostname } from "node:os"; import { MAX_AGENT_HOST_CHARS } from "./protocol.ts"; import type { AgentRecord, DeliveryMode, HubEvent, LeaseRecord, MessageRecord, WorkflowMessageContext } from "./protocol.ts"; import type { ContextAuthority, ContextConfidence, ContextItem, ContextItemAuditMetadata, ContextItemKind, ContextPacket } from "./context.ts"; import { canonicalWorkflowEvidenceKey, type ImprovementArea, type ImprovementAreaReport, type ImprovementSignal, type JournalCategory, type WorkflowCheckpointStatus, type WorkflowEvidenceInput, type WorkflowEvidenceReferenceInput, type WorkflowJournalEntry, type WorkflowRun, } from "./workflow.ts"; /** Metadata-only audit of an assembled context packet. Never contains raw * item bodies. */ export interface ContextRequestAudit { request: { project: string; role: string; task: string; workflowRunId?: string; stageId?: string }; selectedIds: string[]; provenanceSummary: Record<string, number>; estimatedTokens: number; budgetTokens: number; candidateCount: number; excludedSuperseded: number; unresolvedGaps: string[]; /** Numbers only: distinct task tokens, eligible candidates sharing a task * token, and each selected item's rounded relevance (index-aligned with * selectedIds). */ relevance: { taskTokens: number; matchedCandidates: number; selected: number[] }; } /** One recall result: bounded metadata plus the item's rounded relevance to * the query. Never includes the summary. */ export type ContextRecallItem = ContextItemAuditMetadata & { relevance: number }; export interface ContextExplanation { found: boolean; lineage: string[]; evidenceRefs: string[]; sources: { id: string; sourceType: string; sourceRef?: string }[]; } export interface ContextWikiCompilation { audit: { project: string; pages: string[]; stateItems: number; contextItems: number; contradictions: number; compiledAt: string; }; pages: { path: string; content: string }[]; } export interface HubClientOptions { serverUrl: string; authToken?: string; name: string; purpose: string; project: string; model?: string; /** Label for the box this client runs on, declared at registration. * Defaults to the machine hostname; the hub records it for readers and * never uses it to authorize anything. */ host?: string; heartbeatMs?: number; reconnectMs?: number; requestTimeoutMs?: number; /** Injectable fetch for CLI/tests; defaults to global fetch. */ fetchImpl?: typeof fetch; } export interface SendOptions { target: string; content: string; delivery?: DeliveryMode; hops?: number; maxHops?: number; correlationId?: string; replyTo?: string; idempotencyKey?: string; workflowContext?: Omit<WorkflowMessageContext, "schema">; ttlMs?: number; allowOffline?: boolean; } export interface FanoutResult { target: string; messageId?: string; status: "pending" | "replied" | "cancelled" | "expired" | "error"; messageStatus?: "queued" | "delivered"; expiresAt?: string; waitStatus?: "timed_out" | "aborted"; reply?: string; error?: string; } class MeshWaitError extends Error { readonly waitStatus: "timed_out" | "aborted"; constructor(waitStatus: "timed_out" | "aborted", messageId: string) { super(waitStatus === "aborted" ? "await cancelled" : `timed out waiting for ${messageId}`); this.name = "MeshWaitError"; this.waitStatus = waitStatus; } } /** The box label a client declares when it registers. Bounded to the hub's * limit so a long hostname cannot turn registration into a 400, and dropped * entirely when the platform cannot report one. */ function defaultHostLabel(): string | undefined { try { return hostname().trim().slice(0, MAX_AGENT_HOST_CHARS) || undefined; } catch { return undefined; } } function completedFanoutResult(target: string, message: MessageRecord): FanoutResult { if (message.status === "queued" || message.status === "delivered") { throw new Error(`message ${message.id} is not complete`); } return { target, messageId: message.id, status: message.status, ...(message.reply ? { reply: message.reply.content } : {}), ...(message.error ? { error: message.error } : {}), }; } function fanoutIdempotencyKey( prefix: string, target: string, correlationId?: string, workflowContext?: Omit<WorkflowMessageContext, "schema">, ): string { // Preserve the exact pre-0.4.3 hash input when no workflow provenance is // requested. A pending fanout created before upgrade must remain an exact // idempotent retry instead of dispatching duplicate work. const scope = JSON.stringify(workflowContext ? { prefix, correlationId: correlationId ?? null, target: target.toLowerCase(), workflowContext: { runId: workflowContext.runId, stageId: workflowContext.stageId, requirementKey: canonicalWorkflowEvidenceKey(workflowContext.requirementKey), attempt: workflowContext.attempt, }, } : { prefix, correlationId: correlationId ?? null, target: target.toLowerCase(), }); return `fanout:${createHash("sha256").update(scope).digest("hex")}`; } export class HubHttpError extends Error { readonly statusCode: number; readonly code?: string; readonly requestId?: string; readonly extras?: Record<string, unknown>; constructor( statusCode: number, message: string, code?: string, requestId?: string, extras?: Record<string, unknown>, ) { super(message); this.name = "HubHttpError"; this.statusCode = statusCode; if (code) this.code = code; if (requestId) this.requestId = requestId; if (extras) this.extras = extras; } } export class HubClient { readonly options: HubClientOptions; agent: AgentRecord | undefined; private agentKey: string | undefined; private heartbeatTimer?: NodeJS.Timeout; private eventsAbort?: AbortController; private stopped = true; private onEvent: ((event: HubEvent) => void | Promise<void>) | undefined; private registration: Promise<AgentRecord> | undefined; private eventLoop: Promise<void> | undefined; constructor(options: HubClientOptions) { this.options = { heartbeatMs: 10_000, reconnectMs: 1_000, requestTimeoutMs: 15_000, ...options }; } async start(onEvent: (event: HubEvent) => void | Promise<void>): Promise<AgentRecord> { if (!this.stopped) throw new Error("hub client is already started"); this.stopped = false; this.onEvent = onEvent; try { await this.register(); } catch (error) { this.stopped = true; throw error; } this.heartbeatTimer = setInterval(() => void this.heartbeat(), this.options.heartbeatMs); this.heartbeatTimer.unref(); this.eventLoop = this.runEventLoop(); return this.agent!; } async stop(): Promise<void> { if (this.stopped) return; this.stopped = true; this.eventsAbort?.abort(); if (this.heartbeatTimer) clearInterval(this.heartbeatTimer); await this.eventLoop; if (this.agent) { try { await this.request(`/v1/agents/${encodeURIComponent(this.agent.id)}`, { method: "DELETE" }); } catch { // The hub may already be gone during process shutdown. } } this.agent = undefined; this.agentKey = undefined; this.onEvent = undefined; this.eventLoop = undefined; } /** Online peers of this client's project. `includeOffline` also returns * registered members whose lease the hub has already retired. */ async listAgents(options: { includeOffline?: boolean } = {}): Promise<AgentRecord[]> { const path = options.includeOffline ? "/v1/agents?includeOffline=true" : "/v1/agents"; const result = await this.request<{ agents: AgentRecord[] }>(path); return result.agents; } async send(options: SendOptions): Promise<MessageRecord> { const result = await this.request<{ message: MessageRecord }>("/v1/messages", { method: "POST", body: JSON.stringify(options), }); return result.message; } async fanout(options: { targets: string[]; content: string; correlationId?: string; idempotencyKeyPrefix?: string; workflowContext?: Omit<WorkflowMessageContext, "schema">; ttlMs?: number; timeoutMs?: number; signal?: AbortSignal; hops?: number; maxHops?: number; }): Promise<FanoutResult[]> { const targets = [...new Set(options.targets.map((target) => target.trim().toLowerCase()).filter(Boolean))]; if (targets.length < 1 || targets.length > 3) throw new Error("fanout requires between one and three unique targets"); return await Promise.all(targets.map(async (target): Promise<FanoutResult> => { let message: MessageRecord | undefined; try { message = await this.send({ target, content: options.content, delivery: "followUp", ...(options.correlationId ? { correlationId: options.correlationId } : {}), ...(options.workflowContext ? { workflowContext: options.workflowContext } : {}), ...(options.idempotencyKeyPrefix ? { idempotencyKey: fanoutIdempotencyKey( options.idempotencyKeyPrefix, target, options.correlationId, options.workflowContext, ), } : {}), ...(options.ttlMs ? { ttlMs: options.ttlMs } : {}), ...(options.hops !== undefined ? { hops: options.hops } : {}), ...(options.maxHops !== undefined ? { maxHops: options.maxHops } : {}), }); const completed = await this.awaitResponse( message.id, options.timeoutMs ?? 30 * 60_000, options.signal, ); return completedFanoutResult(target, completed); } catch (error) { if (message && error instanceof MeshWaitError) { try { const current = await this.getMessage(message.id); if (current.status === "queued" || current.status === "delivered") { return { target, messageId: current.id, status: "pending", messageStatus: current.status, expiresAt: current.expiresAt, waitStatus: error.waitStatus, }; } return completedFanoutResult(target, current); } catch (finalError) { return { target, messageId: message.id, status: "error", error: finalError instanceof Error ? finalError.message : String(finalError), }; } } return { target, ...(message ? { messageId: message.id } : {}), status: "error", error: error instanceof Error ? error.message : String(error), }; } })); } /** This agent's open inbound requests (queued or delivered), oldest first. A read: nothing * is acknowledged. */ async listInbox(): Promise<MessageRecord[]> { if (!this.agent) throw new Error("hub client is not registered"); const result = await this.request<{ messages: MessageRecord[] }>( `/v1/agents/${encodeURIComponent(this.agent.id)}/inbox`, ); return result.messages; } async getMessage(messageId: string): Promise<MessageRecord> { const result = await this.request<{ message: MessageRecord }>(`/v1/messages/${encodeURIComponent(messageId)}`); return result.message; } async acknowledge(messageId: string): Promise<MessageRecord> { const result = await this.request<{ message: MessageRecord }>( `/v1/messages/${encodeURIComponent(messageId)}/ack`, { method: "POST", body: "{}" }, ); return result.message; } async reply(messageId: string, content: string): Promise<MessageRecord> { const result = await this.request<{ message: MessageRecord }>( `/v1/messages/${encodeURIComponent(messageId)}/reply`, { method: "POST", body: JSON.stringify({ content }) }, ); return result.message; } async cancel(messageId: string): Promise<MessageRecord> { const result = await this.request<{ message: MessageRecord }>( `/v1/messages/${encodeURIComponent(messageId)}`, { method: "DELETE" }, ); return result.message; } // ----- Fenced leases over shared resources (P3) ----- /** * Take or extend the lease over `resource` inside this client's project. * * The returned `fencingToken` is the whole point: hold it, present it on every * renewal, and present it again before committing anything shared. A hub that * has moved past it refuses, and the caller must stop rather than retry — * another holder owns the resource now. Rejects `HubHttpError` with code * `lease_held` when a live holder has it. */ async acquireLease(resource: string, ttlMs?: number): Promise<{ lease: LeaseRecord; renewed: boolean }> { return await this.request(`/v1/leases/${encodeURIComponent(resource)}/acquire`, { method: "POST", body: JSON.stringify(ttlMs === undefined ? {} : { ttlMs }), }); } /** Extend a lease this client holds. The token never changes on renewal; a * `lease_superseded` or `lease_expired` refusal means it is gone. */ async renewLease(resource: string, fencingToken: number, ttlMs?: number): Promise<{ lease: LeaseRecord }> { return await this.request(`/v1/leases/${encodeURIComponent(resource)}/renew`, { method: "POST", body: JSON.stringify(ttlMs === undefined ? { fencingToken } : { fencingToken, ttlMs }), }); } /** Give the resource back. */ async releaseLease(resource: string, fencingToken: number): Promise<{ released: boolean; lease: LeaseRecord }> { return await this.request(`/v1/leases/${encodeURIComponent(resource)}/release`, { method: "POST", body: JSON.stringify({ fencingToken }), }); } async listWorkflows(): Promise<WorkflowRun[]> { const result = await this.request<{ runs: WorkflowRun[] }>("/v1/workflows"); return result.runs; } async getWorkflow(runId: string): Promise<{ run: WorkflowRun; journal: WorkflowJournalEntry[] }> { return await this.request(`/v1/workflows/${encodeURIComponent(runId)}`); } async checkpointWorkflow( runId: string, input: { stageId: string; status: WorkflowCheckpointStatus; summary: string; evidence?: WorkflowEvidenceInput; evidenceRefs?: WorkflowEvidenceReferenceInput; }, ): Promise<{ run: WorkflowRun; retry: boolean; completed: boolean; instruction: string }> { return await this.request(`/v1/workflows/${encodeURIComponent(runId)}/checkpoints`, { method: "POST", body: JSON.stringify(input), }); } async waitForWorkflowSignal( runId: string, input: { stageId: string; signalKey: string; summary: string; evidence?: WorkflowEvidenceInput; evidenceRefs?: WorkflowEvidenceReferenceInput; timeoutMs?: number; }, ): Promise<{ run: WorkflowRun; instruction: string }> { return await this.request(`/v1/workflows/${encodeURIComponent(runId)}/waits`, { method: "POST", body: JSON.stringify(input), }); } async recordWorkflowEntry( runId: string, input: { category: JournalCategory; /** Required unless stageId names a stage that declares an area. */ area?: ImprovementArea; severity?: "info" | "warning" | "error"; summary: string; details?: string; evidence?: string[]; relatedEntryIds?: string[]; /** Stage the entry is recorded against; binds run/stage/attempt * provenance. The hub derives the attempt; callers never supply one. */ stageId?: string; }, ): Promise<WorkflowJournalEntry> { const result = await this.request<{ entry: WorkflowJournalEntry }>( `/v1/workflows/${encodeURIComponent(runId)}/journal`, { method: "POST", body: JSON.stringify(input) }, ); return result.entry; } async improvementReport(): Promise<{ reports: ImprovementAreaReport[]; signals: ImprovementSignal[]; entries: number }> { return await this.request("/v1/improvements"); } // ----- Context operating-system API (v0.5, issue #34) ----- async contextGet(input: { project: string; role: string; task: string; workflowRunId?: string; stageId?: string; budgetTokens?: number; includeKinds?: ContextItemKind[]; }): Promise<{ packet: ContextPacket; audit: ContextRequestAudit }> { return await this.request("/v1/context/get", { method: "POST", body: JSON.stringify(input) }); } async contextRecall(input: { project: string; query?: string; kinds?: ContextItemKind[]; limit?: number; }): Promise<{ items: ContextRecallItem[]; unresolvedGaps: string[] }> { return await this.request("/v1/context/recall", { method: "POST", body: JSON.stringify(input) }); } async contextState(input: { project: string; key: string; asOf?: string; }): Promise<{ state: ContextItem | null; key: string }> { return await this.request("/v1/context/state", { method: "POST", body: JSON.stringify(input) }); } async contextStatePropose(input: { project: string; key: string; summary: string; authority: ContextAuthority; confidence: ContextConfidence; evidenceRefs: string[]; }): Promise<{ proposalId: string }> { return await this.request("/v1/context/state/propose", { method: "POST", body: JSON.stringify(input) }); } async contextStatePromote(input: { project: string; proposalId: string; evidence: string[]; }): Promise<{ state: ContextItem }> { return await this.request("/v1/context/state/promote", { method: "POST", body: JSON.stringify(input) }); } async contextEpisode(input: { project: string; workflowRunId?: string; }): Promise<{ episodes: ContextItem[] }> { return await this.request("/v1/context/episode", { method: "POST", body: JSON.stringify(input) }); } async contextExplain(input: { project: string; id: string; }): Promise<ContextExplanation> { return await this.request("/v1/context/explain", { method: "POST", body: JSON.stringify(input) }); } async contextWikiCompile(input: { project: string }): Promise<ContextWikiCompilation> { return await this.request("/v1/context/wiki/compile", { method: "POST", body: JSON.stringify(input) }); } async awaitResponse(messageId: string, timeoutMs = 30 * 60_000, signal?: AbortSignal): Promise<MessageRecord> { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { if (signal?.aborted) throw new MeshWaitError("aborted", messageId); const message = await this.getMessage(messageId); if (["replied", "cancelled", "expired", "error"].includes(message.status)) return message; await new Promise<void>((resolve, reject) => { const onAbort = () => { signal?.removeEventListener("abort", onAbort); clearTimeout(timer); reject(new MeshWaitError("aborted", messageId)); }; const timer = setTimeout(() => { signal?.removeEventListener("abort", onAbort); resolve(); }, Math.min(500, Math.max(1, deadline - Date.now()))); signal?.addEventListener("abort", onAbort, { once: true }); if (signal?.aborted) onAbort(); timer.unref(); }); } throw new MeshWaitError("timed_out", messageId); } private async heartbeat(): Promise<void> { if (this.stopped || !this.agent) return; try { await this.request(`/v1/agents/${encodeURIComponent(this.agent.id)}/heartbeat`, { method: "POST", body: "{}", }); } catch (error) { if (error instanceof HubHttpError && error.statusCode === 401) void this.recoverRegistration(); } } private async runEventLoop(): Promise<void> { while (!this.stopped && this.agent) { this.eventsAbort = new AbortController(); try { const response = await (this.options.fetchImpl ?? fetch)( `${this.options.serverUrl.replace(/\/$/, "")}/v1/events?agentId=${encodeURIComponent(this.agent.id)}`, { headers: this.headers(), signal: this.eventsAbort.signal, }, ); if (response.status === 401) { await response.body?.cancel(); await this.recoverRegistration(); continue; } if (!response.ok || !response.body) throw new Error(`event stream failed with HTTP ${response.status}`); const decoder = new TextDecoder(); let buffer = ""; for await (const chunk of response.body) { if (this.stopped) break; buffer += decoder.decode(chunk, { stream: true }).replaceAll("\r\n", "\n"); let boundary: number; while ((boundary = buffer.indexOf("\n\n")) >= 0) { const frame = buffer.slice(0, boundary); buffer = buffer.slice(boundary + 2); const data = frame .split("\n") .filter((line) => line.startsWith("data:")) .map((line) => line.slice(5).trimStart()) .join("\n"); if (!data) continue; const parsed = JSON.parse(data) as HubEvent | { agent: AgentRecord }; if ("type" in parsed) await this.onEvent?.(parsed); } } } catch (error) { if (this.stopped || (error instanceof Error && error.name === "AbortError")) return; } if (!this.stopped) await new Promise((resolve) => setTimeout(resolve, this.options.reconnectMs)); } } private headers(includeIdentity = true): Record<string, string> { const headers: Record<string, string> = { "content-type": "application/json" }; if (this.options.authToken) headers.authorization = `Bearer ${this.options.authToken}`; if (includeIdentity && this.agent && this.agentKey) { headers["x-kxm-agent-id"] = this.agent.id; headers["x-kxm-agent-key"] = this.agentKey; } return headers; } private async register(): Promise<AgentRecord> { if (this.registration) return this.registration; this.registration = (async () => { const registration = await this.request<{ agent: AgentRecord; agentKey: string }>("/v1/agents/register", { method: "POST", body: JSON.stringify({ name: this.options.name, purpose: this.options.purpose, project: this.options.project, model: this.options.model, host: this.options.host ?? defaultHostLabel(), }), }, false); this.agent = registration.agent; this.agentKey = registration.agentKey; return registration.agent; })(); try { return await this.registration; } finally { this.registration = undefined; } } private async recoverRegistration(): Promise<void> { if (this.stopped) return; this.agent = undefined; this.agentKey = undefined; await this.register(); } private async request<T = unknown>(path: string, init: RequestInit = {}, includeIdentity = true): Promise<T> { return await hubJsonRequest<T>(this.options, path, init, this.headers(includeIdentity)); } } /** One JSON request to the hub with a bounded timeout. A non-2xx answer * becomes a `HubHttpError` carrying the hub's code and next-action hints. */ async function hubJsonRequest<T>( options: { serverUrl: string; requestTimeoutMs?: number; fetchImpl?: typeof fetch }, path: string, init: RequestInit, headers: Record<string, string>, ): Promise<T> { const requestTimeoutMs = options.requestTimeoutMs ?? 15_000; const timeoutSignal = AbortSignal.timeout(requestTimeoutMs); const signal = init.signal ? AbortSignal.any([init.signal, timeoutSignal]) : timeoutSignal; let response: Response; try { response = await (options.fetchImpl ?? fetch)(`${options.serverUrl.replace(/\/$/, "")}${path}`, { ...init, signal, headers: { ...headers, ...(init.headers ?? {}) }, }); } catch (error) { if (timeoutSignal.aborted) throw new Error(`request timed out after ${requestTimeoutMs}ms`); throw error; } const text = await response.text(); let body: Record<string, unknown> = {}; if (text) { try { body = JSON.parse(text) as Record<string, unknown>; } catch { throw new Error(`hub returned invalid JSON with HTTP ${response.status}`); } } if (!response.ok) { const extras: Record<string, unknown> = {}; for (const key of ["operation", "nextAction", "assignedCoordinatorName"]) { if (typeof body[key] === "string") extras[key] = body[key]; } // A lease refusal carries the lease that won, so the loser can record who // holds the resource and at which token instead of guessing. if (body.lease && typeof body.lease === "object") extras.lease = body.lease; throw new HubHttpError( response.status, String(body.error ?? `HTTP ${response.status}`), typeof body.code === "string" ? body.code : undefined, response.headers.get("x-request-id") ?? undefined, Object.keys(extras).length > 0 ? extras : undefined, ); } return body as T; } export interface RuntimeHubClientOptions { serverUrl: string; /** The hub project this Runtime reports into; its token is the admission. */ project: string; authToken?: string; runtimeId: string; /** Box label; defaults to the hostname. Never used for authorization. */ host?: string; requestTimeoutMs?: number; fetchImpl?: typeof fetch; } export interface RuntimePresenceView { runtimeId: string; host?: string; registeredAt: string; heartbeatAt: string; leaseExpiresAt: string; presence: "online" | "expired"; } export interface SyncPushResult { projectId?: string; runId?: string; sequence?: number; outcome: "accepted" | "duplicate" | "conflict" | "rejected"; code?: string; } export interface SyncPushResponse { results: SyncPushResult[]; cursors: Array<{ projectId: string; runId: string; cursor: number }>; } /** * The Runtime's machine client for one hub project (P5). It registers * presence and pushes derived sync events outbound; the hub never calls the * Runtime back. No agent identity, no message verbs. */ export class RuntimeHubClient { readonly options: RuntimeHubClientOptions; constructor(options: RuntimeHubClientOptions) { this.options = options; } async heartbeat(): Promise<RuntimePresenceView> { const result = await this.request<{ presence: RuntimePresenceView }>("/v1/runtime/presence", { project: this.options.project, runtimeId: this.options.runtimeId, host: this.options.host ?? defaultHostLabel(), }); return result.presence; } /** Push already-derived `kxm.sync-event.v1` objects, in outbox order. */ async pushSyncEvents(events: readonly unknown[]): Promise<SyncPushResponse> { return await this.request<SyncPushResponse>("/v1/sync/events", { project: this.options.project, runtimeId: this.options.runtimeId, events, }); } private async request<T>(path: string, body: unknown): Promise<T> { const headers: Record<string, string> = { "content-type": "application/json" }; if (this.options.authToken) headers.authorization = `Bearer ${this.options.authToken}`; return await hubJsonRequest<T>(this.options, path, { method: "POST", body: JSON.stringify(body) }, headers); } }