UNPKG

@kontextmind/kxm

Version:

KXM local-first multi-agent orchestration and operator dashboard

401 lines (376 loc) • 15.4 kB
import { DEFAULT_CONTEXT_BUDGET_TOKENS, MAX_CONTEXT_ITEMS, contextItemCharacters, estimateContextTokens, parseContextItem, parseContextRequest, provenanceSummaryOf, validateContextPacketContents, CONTEXT_SOURCE_TYPES, type ContextAuthority, type ContextItem, type ContextItemKind, type ContextPacket, type ContextRequest, type ContextSourceType, } from "./context.ts"; import { ProtocolError } from "./protocol.ts"; import { compareCodeUnitIds, contextItemRelevanceText, relevanceTokens, roundRelevance, scoreRelevance, } from "./relevance.ts"; import type { SkillLifecycle } from "./skills.ts"; import type { JournalCategory, WorkflowJournalEntry } from "./workflow.ts"; import type { MemoryRecord } from "./memory.ts"; /** * Role-aware context arbiter (v0.5, issue #34). * * The arbiter assembles context packets from the durable pool (journal * evidence, temporal state, knowledge, episodes, skills) for one role, task, * and token budget. Selection is deterministic: the same pool and request * always produce the same packet, so packets are testable, explainable, and * reproducible. */ export interface RoleContextPolicy { role: string; label: string; /** Item kinds this role receives, in priority order. */ kinds: ContextItemKind[]; /** Journal categories this role recalls from, in priority order. */ journalCategories: JournalCategory[]; /** Fixed default token budget for this role. */ budgetTokens: number; } export const ROLE_POLICIES: readonly RoleContextPolicy[] = [ { role: "repro", label: "Reproduction specialist", kinds: ["episode", "knowledge", "evidence"], journalCategories: ["error", "lesson", "observation", "contradiction"], budgetTokens: 8_000, }, { role: "planner", label: "Planner", kinds: ["state", "knowledge", "evidence"], journalCategories: ["plan", "decision", "contradiction", "observation", "hypothesis", "experiment"], budgetTokens: 16_000, }, { role: "critic", label: "Independent critic", kinds: ["knowledge", "evidence", "episode"], journalCategories: ["contradiction", "error", "lesson", "experiment"], budgetTokens: 12_000, }, { role: "implementer", label: "Implementer", kinds: ["knowledge", "state", "skill", "episode", "evidence"], journalCategories: ["plan", "decision", "lesson", "state-change"], budgetTokens: 16_000, }, { role: "verifier", label: "Verifier", kinds: ["evidence", "knowledge", "episode"], journalCategories: ["plan", "error", "lesson", "contradiction"], budgetTokens: 8_000, }, ]; export function rolePolicy(role: string): RoleContextPolicy { return ROLE_POLICIES.find((policy) => policy.role === role) ?? { role, label: role, kinds: ["knowledge", "evidence"], journalCategories: ["lesson", "observation"], budgetTokens: DEFAULT_CONTEXT_BUDGET_TOKENS, }; } const CONFIDENCE_RANK: Record<ContextItem["confidence"], number> = { verified: 3, probable: 2, uncertain: 1 }; const AUTHORITY_WEIGHT: Record<ContextItem["authority"], number> = { policy: 3, instruction: 2, evidence: 1, hypothesis: 0 }; export interface ArbiterOptions { /** Hub-owned working state (run/stage/attempt) delivered verbatim. */ workingState?: Record<string, unknown>; /** Pool item IDs that represent open contradictions; they are routed to the * packet's contradictions section instead of their kind's section. */ contradictionIds?: string[]; /** Governed skill lifecycle (or a pre-verified snapshot of one) to read and * verify promoted skills by hash. */ skillLifecycle?: Pick<SkillLifecycle, "list" | "verify">; } export interface ArbiterOutcome { packet: ContextPacket; /** Metadata-only telemetry: what was selected, from where, at what cost. * Never includes raw bodies. */ audit: { 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 BM25 score (index-aligned with * selectedIds). */ relevance: { taskTokens: number; matchedCandidates: number; selected: number[] }; }; } /** Deterministically assemble a role-aware context packet. Superseded and * rejected records are excluded by default; cross-project content fails * closed; eligible candidates are ranked by contradiction, project-first, * task match, role kind priority, BM25 task relevance, confidence, * authority, recency (newest first), then id by code unit; the token budget * is filled first-fit on the serialized selection. */ export function arbitrate( requestInput: unknown, pool: ContextItem[], options: ArbiterOptions = {}, ): ArbiterOutcome { const request = parseContextRequest(requestInput); const policy = rolePolicy(request.role); const budget = request.budgetTokens ?? policy.budgetTokens; const contradictions = new Set(options.contradictionIds ?? []); const candidates: ContextItem[] = []; let excludedSuperseded = 0; for (const candidate of pool) { if (candidate.project !== request.project && candidate.project !== "_shared") { // Defense in depth: the hub pre-filters by project, so a foreign item // in the pool is a bug — fail closed rather than silently filter. throw new ProtocolError( 403, `context pool contains cross-project item ${candidate.id}`, "context_isolation_violation", ); } if (candidate.status === "superseded" || candidate.status === "rejected") { excludedSuperseded += 1; continue; } candidates.push(candidate); } if (options.skillLifecycle) { for (const metadata of options.skillLifecycle.list("promoted")) { try { options.skillLifecycle.verify("promoted", metadata.id); candidates.push(parseContextItem({ id: `skill_${metadata.id}`, kind: "skill", project: request.project, summary: metadata.description ? `${metadata.name}: ${metadata.description}` : metadata.name, provenance: { sourceType: "git", sourceRef: `skill:${metadata.id}@${metadata.contentSha256}`, }, authority: "instruction", confidence: "verified", status: "current", })); } catch { // Tampered or invalid promoted skill: omit by hash check } } } const kindRank = new Map<string, number>(); const requestedKinds = request.includeKinds ?? policy.kinds; requestedKinds.forEach((kind, index) => kindRank.set(kind, index)); const kindPreference = (item: ContextItem): number => { const rank = kindRank.get(item.kind); return rank === undefined ? requestedKinds.length : rank; }; // Eligibility: open contradictions always compete; everything else must be // a requested kind and not an inert proposal (a non-current state or a // proposed skill), so proposals never consume budget. const allowedKinds = new Set<ContextItemKind>(requestedKinds); const inertProposal = (item: ContextItem): boolean => (item.kind === "state" && item.status !== undefined && item.status !== "current") || (item.kind === "skill" && item.status === "proposed"); const eligible = candidates.filter((item) => contradictions.has(item.id) || (allowedKinds.has(item.kind) && !inertProposal(item))); const scoreList = scoreRelevance(request.task, eligible.map(contextItemRelevanceText)); const scores = new Map<ContextItem, number>(); eligible.forEach((item, index) => scores.set(item, scoreList[index] ?? 0)); const scoreOf = (item: ContextItem): number => scores.get(item) ?? 0; // Recency from the item's own timestamps; never reads the clock. const when = (item: ContextItem): number => { const parsed = Date.parse(item.observedAt ?? item.validFrom ?? ""); return Number.isFinite(parsed) ? parsed : 0; }; const ordered = [...eligible].sort((left, right) => (contradictions.has(right.id) ? 1 : 0) - (contradictions.has(left.id) ? 1 : 0) || (left.project === request.project ? 0 : 1) - (right.project === request.project ? 0 : 1) || (scoreOf(right) > 0 ? 1 : 0) - (scoreOf(left) > 0 ? 1 : 0) || kindPreference(left) - kindPreference(right) || scoreOf(right) - scoreOf(left) || CONFIDENCE_RANK[right.confidence] - CONFIDENCE_RANK[left.confidence] || AUTHORITY_WEIGHT[right.authority] - AUTHORITY_WEIGHT[left.authority] || when(right) - when(left) || compareCodeUnitIds(left.id, right.id), ); // First-fit: an item that does not fit is deferred and smaller items keep // filling the budget. const selected: ContextItem[] = []; const unresolvedGaps: string[] = []; let characters = 0; let deferredForBudget = 0; for (const item of ordered) { if (selected.length >= MAX_CONTEXT_ITEMS) { unresolvedGaps.push("context item limit reached; refine the task or kinds"); break; } const itemCharacters = contextItemCharacters(item); if (Math.ceil((characters + itemCharacters) / 4) > budget) { deferredForBudget += 1; continue; } characters += itemCharacters; selected.push(item); } if (deferredForBudget > 0) { unresolvedGaps.push(selected.length === 0 ? `budget of ${budget} tokens cannot fit any selected context` : `budget of ${budget} tokens reached; ${deferredForBudget} candidates deferred`); } if (candidates.length === 0) { unresolvedGaps.push("no context records exist for this project yet"); } const bySection = (kind: ContextItemKind): ContextItem[] => selected.filter((item) => item.kind === kind && !contradictions.has(item.id)); const packet: ContextPacket = { workingState: options.workingState ?? {}, currentState: bySection("state").filter((item) => item.status === "current" || item.status === undefined), knowledge: bySection("knowledge"), evidence: bySection("evidence"), episodes: bySection("episode"), skills: bySection("skill").filter((item) => item.status !== "proposed"), contradictions: selected.filter((item) => contradictions.has(item.id)), unresolvedGaps, provenanceSummary: provenanceSummaryOf(selected), estimatedTokens: estimateContextTokens(selected), }; validateContextPacketContents(request, packet); return { packet, audit: { request: { project: request.project, role: request.role, task: request.task, ...(request.workflowRunId !== undefined ? { workflowRunId: request.workflowRunId } : {}), ...(request.stageId !== undefined ? { stageId: request.stageId } : {}), }, selectedIds: selected.map((item) => item.id), provenanceSummary: packet.provenanceSummary, estimatedTokens: packet.estimatedTokens, budgetTokens: budget, candidateCount: candidates.length, excludedSuperseded, unresolvedGaps, relevance: { taskTokens: new Set(relevanceTokens(request.task)).size, matchedCandidates: scoreList.filter((score) => score > 0).length, selected: selected.map((item) => roundRelevance(scoreOf(item))), }, }, }; } /** Convert a durable journal entry into a pool context item. Journal content * is evidence about work, never policy: entries become `evidence` or * `knowledge` items with workflow provenance and the entry as sourceRef. */ export function journalEntryToContextItem(entry: WorkflowJournalEntry, project: string): ContextItem { const kind: ContextItemKind = entry.category === "plan" || entry.category === "decision" || entry.category === "lesson" ? "knowledge" : entry.category === "skill-candidate" ? "skill" : "evidence"; const item: ContextItem = { id: `journal_${entry.id}`, kind, project, summary: entry.summary, provenance: { sourceType: "workflow", sourceRef: `journal:${entry.id}`, }, authority: entry.category === "decision" || entry.category === "plan" ? "evidence" : "evidence", confidence: entry.severity === "error" ? "probable" : "probable", observedAt: entry.createdAt, evidenceRefs: entry.evidence .filter((ref) => ref.length > 0 && ref.length <= 200) .slice(0, 16), }; if (kind === "skill") item.status = "proposed"; return parseContextItem(item); } /** Convert an authored memory record into a pool context item. Git-authored * records have instruction authority. Records scoped to 'operator' are placed * in the '_shared' project namespace so they can be consumed cross-project * as shared defaults without violating project boundaries. */ export function memoryRecordToContextItem(record: MemoryRecord, project: string): ContextItem { let authority: ContextAuthority = "instruction"; if (record.authority === "evidence") { authority = "evidence"; } const sourceType: ContextSourceType = CONTEXT_SOURCE_TYPES.includes(record.provenance?.sourceType as ContextSourceType) ? (record.provenance.sourceType as ContextSourceType) : "git"; const targetProject = record.scope === "operator" ? "_shared" : project; return parseContextItem({ id: `mem_${record.id}`, kind: "knowledge", scope: record.scope, project: targetProject, summary: record.summary, provenance: { sourceType, sourceRef: record.provenance?.sourceRef ?? `memory:${record.id}.md`, }, authority, confidence: record.confidence, status: record.lifecycle === "active" ? "current" : "superseded", evidenceRefs: record.evidenceRefs && record.evidenceRefs.length > 0 ? record.evidenceRefs : undefined, }); } /** Which pool records support a claim: the item itself plus its full * derivation lineage and evidence refs, metadata only (no raw bodies). */ export function explainContextItem(id: string, pool: ContextItem[]): { item: ContextItem | undefined; lineage: string[]; evidenceRefs: string[]; sources: { id: string; sourceType: string; sourceRef?: string }[]; } { const byId = new Map(pool.map((item) => [item.id, item])); const item = byId.get(id); if (!item) return { item: undefined, lineage: [], evidenceRefs: [], sources: [] }; const lineage: string[] = []; const queue = [...(item.provenance.derivedFrom ?? [])]; const seen = new Set<string>(); while (queue.length > 0) { const ancestorId = queue.shift()!; if (seen.has(ancestorId)) continue; seen.add(ancestorId); lineage.push(ancestorId); const ancestor = byId.get(ancestorId); for (const older of ancestor?.provenance.derivedFrom ?? []) queue.push(older); } lineage.sort(); const sources = [item, ...lineage.map((ancestorId) => byId.get(ancestorId))] .filter((candidate): candidate is ContextItem => candidate !== undefined) .map((candidate) => ({ id: candidate.id, sourceType: candidate.provenance.sourceType, ...(candidate.provenance.sourceRef !== undefined ? { sourceRef: candidate.provenance.sourceRef } : {}), })); return { item, lineage, evidenceRefs: item.evidenceRefs ?? [], sources, }; }