From 1069eb859cc6a503b7fd2b284c28c13aa77ea64f Mon Sep 17 00:00:00 2001 From: EmersonBraun Date: Thu, 27 Aug 2026 13:11:44 -0300 Subject: [PATCH] feat(agents): add local Registry proposal adapter --- src/agents/registry-adapter.ts | 97 ++++++++++++++++++++++++++++++++++ src/cli/program.ts | 23 ++++++++ src/config/schema.ts | 9 ++++ src/index.ts | 1 + src/mcp/server.ts | 15 +++++- tests/registry-adapter.test.ts | 43 +++++++++++++++ 6 files changed, 186 insertions(+), 2 deletions(-) create mode 100644 src/agents/registry-adapter.ts create mode 100644 tests/registry-adapter.test.ts diff --git a/src/agents/registry-adapter.ts b/src/agents/registry-adapter.ts new file mode 100644 index 0000000..332d176 --- /dev/null +++ b/src/agents/registry-adapter.ts @@ -0,0 +1,97 @@ +import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +import { z } from 'zod' + +import type { DocBridgeConfigV1 } from '../config/schema.js' +import { AgentProposalV1Schema, type AgentProposalV1, type DiscoverySnapshotV1, type ReconciliationReportV1 } from '../schemas/knowledge.js' +import { contentHashForArtifactV1 } from '../index-builder/content-hash.js' +import { containedPath, redactValue } from '../safety/repository.js' + +export const DEFAULT_REGISTRY_AGENT_ID = 'ecosystem-doc-bridge-corpus-scanner' + +const RegistryAgentMetadataSchema = z.object({ + id: z.string().min(1).max(256), + version: z.string().min(1).max(64), + provider: z.string().min(1).max(128).optional(), + model: z.string().min(1).max(256).optional(), + capabilities: z.array(z.string().min(1).max(128)).max(32).default([]), +}).strict() + +export type RegistryAgentMetadata = z.infer & { readonly root: string } + +export type RegistryAgentContext = { + readonly snapshot: DiscoverySnapshotV1 + readonly report: ReconciliationReportV1 + readonly evidence: readonly ReconciliationReportV1['diagnostics'][number]['evidence'][number][] + readonly capabilities: readonly ['snapshot.read', 'evidence.read', 'proposal.write'] + readonly network: false + readonly shell: false +} + +export type RegistryAgentRunner = (context: RegistryAgentContext) => Promise | unknown + +export type RegistryAgentAdapter = { + readonly metadata: RegistryAgentMetadata + readonly run: (snapshot: DiscoverySnapshotV1, report: ReconciliationReportV1, evidence?: readonly RegistryAgentContext['evidence'][number][]) => Promise +} + +const deepFreeze = (value: T): T => { + if (value && typeof value === 'object' && !Object.isFrozen(value)) { + Object.freeze(value) + for (const child of Object.values(value as Record)) deepFreeze(child) + } + return value +} + +const registryConfig = (config: DocBridgeConfigV1) => config.intelligence?.registry + +export const loadRegistryAgentRunner = async (root: string, config: DocBridgeConfigV1): Promise => { + const metadata = loadRegistryAgentMetadata(root, config) + const configured = registryConfig(config)?.runnerModule + const modulePath = configured ? containedPath(root, configured) : containedPath(root, join(metadata.root, 'doc-bridge-adapter.js')) + if (!modulePath || !existsSync(modulePath)) throw new Error(`Registry agent "${metadata.id}" has no local runner module. Configure intelligence.registry.runnerModule or add doc-bridge-adapter.js to the installed agent.`) + const loaded = await import(pathToFileURL(modulePath).href) as { default?: unknown; run?: unknown } + const runner = typeof loaded.run === 'function' ? loaded.run : typeof loaded.default === 'function' ? loaded.default : loaded.default && typeof loaded.default === 'object' && 'run' in loaded.default && typeof loaded.default.run === 'function' ? loaded.default.run : undefined + if (!runner) throw new Error(`Registry agent runner at ${modulePath} must export a function or { run }. `) + return runner as RegistryAgentRunner +} + +export const loadRegistryAgentMetadata = (root: string, config: DocBridgeConfigV1): RegistryAgentMetadata => { + const settings = registryConfig(config) + const id = settings?.agentId ?? DEFAULT_REGISTRY_AGENT_ID + const agentRoot = settings?.agentRoot ?? 'agents' + const agentPath = containedPath(root, join(agentRoot, id)) + if (!agentPath || !existsSync(agentPath)) throw new Error(`AgentsKit Registry agent "${id}" is not installed at ${join(agentRoot, id)}. Install it with: npx agentskit add ${id}`) + const metadataPath = [join(agentPath, 'agent.json'), join(agentPath, 'manifest.json')].find(existsSync) + if (!metadataPath) throw new Error(`Registry agent "${id}" is installed but has no agent.json or manifest.json metadata.`) + const metadata = RegistryAgentMetadataSchema.parse(JSON.parse(readFileSync(metadataPath, 'utf8')) as unknown) + if (metadata.id !== id) throw new Error(`Installed Registry agent metadata id "${metadata.id}" does not match configured id "${id}".`) + return { ...metadata, root: agentPath } +} + +export const createRegistryAgentAdapter = (root: string, config: DocBridgeConfigV1, runner: RegistryAgentRunner): RegistryAgentAdapter => { + if (!registryConfig(config)?.enabled) throw new Error('Registry agents are disabled. Set intelligence.registry.enabled: true to run an assisted workflow.') + const metadata = loadRegistryAgentMetadata(resolve(root), config) + return { + metadata, + run: async (snapshot, report, evidence = report.diagnostics.flatMap((diagnostic) => diagnostic.evidence).slice(0, 64)) => { + const context = deepFreeze({ snapshot: redactValue(snapshot), report: redactValue(report), evidence: redactValue(evidence), capabilities: ['snapshot.read', 'evidence.read', 'proposal.write'] as const, network: false as const, shell: false as const }) as RegistryAgentContext + const proposal = AgentProposalV1Schema.parse(await runner(context)) + if (proposal.contentHash !== contentHashForArtifactV1(proposal)) throw new Error('Registry agent proposal contentHash does not match its canonical contents.') + if (proposal.baseSnapshotHash !== snapshot.contentHash || proposal.baseReportHash !== report.contentHash) throw new Error('Registry agent proposal is not based on the supplied snapshot/report hashes.') + if (proposal.origin.kind !== 'registry-agent' || proposal.origin.id !== metadata.id) throw new Error(`Registry agent proposal origin must be ${metadata.id}.`) + return proposal + }, + } +} + +export const persistRegistryAgentProposal = (stateDir: string, proposal: AgentProposalV1): string => { + AgentProposalV1Schema.parse(proposal) + if (proposal.contentHash !== contentHashForArtifactV1(proposal)) throw new Error('Cannot persist a Registry agent proposal with an invalid contentHash.') + const safeHash = contentHashForArtifactV1(proposal) + mkdirSync(join(resolve(stateDir), 'agents'), { recursive: true }) + const path = join(resolve(stateDir), 'agents', `${proposal.origin.id}-${safeHash}.json`) + writeFileSync(path, `${JSON.stringify(proposal, null, 2)}\n`, 'utf8') + return path +} diff --git a/src/cli/program.ts b/src/cli/program.ts index 75d5f1f..a4b4b4a 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -45,6 +45,7 @@ import { reconcileKnowledge } from '../reconciliation/reconcile.js' import type { DiscoverySnapshotV1, ReconciliationReportV1 } from '../schemas/knowledge.js' import { sha256NormalizedV1 } from '../index-builder/content-hash.js' import { applyFixProposal, approveFixProposal, createArtifactNormalizationProposal, createMarkdownLinkFixProposal } from '../fixes/proposals.js' +import { createRegistryAgentAdapter, loadRegistryAgentRunner, persistRegistryAgentProposal } from '../agents/registry-adapter.js' import { PACKAGE_VERSION } from '../version.js' type Command = @@ -63,6 +64,7 @@ type Command = | 'check' | 'map' | 'fix' + | 'suggest' | 'index' | 'gate' | 'rules' @@ -89,6 +91,7 @@ Core (no API key): ak-docs scan | reconcile | check | map [--text|--json] ak-docs fix propose links|normalize [--output ] ak-docs fix approve|apply [--by ] + ak-docs suggest [--json|--text] run the configured local Registry agent ak-docs query [package|ownership|intent|change] [--agent] [--text] ak-docs search [--agent] [--text] ak-docs list [--text] @@ -164,6 +167,7 @@ const parseArgs = (argv: readonly string[]) => { else if (positional[0] === 'check') command = 'check' else if (positional[0] === 'map') command = 'map' else if (positional[0] === 'fix') command = 'fix' + else if (positional[0] === 'suggest') command = 'suggest' else if (positional[0] === 'index') command = 'index' else if (positional[0] === 'gate') command = 'gate' else if (positional[0] === 'rules') command = 'rules' @@ -616,6 +620,24 @@ const runFixCommand = (argv: readonly string[], positional: readonly string[], c } } +const runSuggestCommand = async (flags: ReadonlySet, configPath: string | undefined): Promise => { + try { + const { config, root } = loadProject(configPath) + const stateDir = resolve(root, config.workflow?.stateDir ?? '.doc-bridge/workflow') + const snapshot = parseDiscoverySnapshot(loadWorkflowStepOutput(stateDir, 'normalize')) + const report = parseReconciliationReport(loadWorkflowStepOutput(stateDir, 'reconcile')) + const adapter = createRegistryAgentAdapter(root, config, await loadRegistryAgentRunner(root, config)) + const proposal = await adapter.run(snapshot, report) + const proposalPath = persistRegistryAgentProposal(stateDir, proposal) + if (flags.has('--text')) writeLines([`Agent: ${adapter.metadata.id}`, `Proposal: ${proposal.proposalId}`, `Hash: ${proposal.contentHash}`, `Saved: ${proposalPath}`]) + else writeJson({ ok: true, proposal, proposalPath }) + return 0 + } catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`) + return 2 + } +} + const writeIfMissing = (path: string, contents: string): boolean => { if (existsSync(path)) return false mkdirSync(dirname(path), { recursive: true }) @@ -862,6 +884,7 @@ export const runCli = (argv: readonly string[]): number | undefined | Promise(tool: string, schema: z.ZodType, value: unknown): T => { try { @@ -350,6 +351,16 @@ export const handleMcpRequest = (ctx: McpContext, request: JsonRpcRequest): unkn try { proposal = readSavedProposal(ctx, undefined) } catch { proposal = undefined } return textResult(redactValue({ ...(run ? { runId: run.runId } : {}), proposals: proposal ? [proposal] : [] })) } + if (parsed.action === 'suggest') { + const snapshot = workflowSnapshot(ctx) + const report = workflowReport(ctx) + return loadRegistryAgentRunner(ctx.root, ctx.config).then(async (runner) => { + const adapter = createRegistryAgentAdapter(ctx.root, ctx.config, runner) + const proposal = await adapter.run(snapshot, report) + const savedPath = persistRegistryAgentProposal(workflowStateDir(ctx), proposal) + return textResult(redactValue({ ...(run ? { runId: run.runId } : {}), proposal, proposalPath: savedPath })) + }) + } const discovered = discoverRepository({ root: ctx.root, config: ctx.config }) const options = { baseRevision: discovered.sourceRevision, configurationHash: sha256NormalizedV1(ctx.config), ...(ctx.config.project?.name ? { projectName: ctx.config.project.name } : {}) } if (parsed.action === 'propose-links') { diff --git a/tests/registry-adapter.test.ts b/tests/registry-adapter.test.ts new file mode 100644 index 0000000..7a4de16 --- /dev/null +++ b/tests/registry-adapter.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest' +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { applyConfigDefaults } from '../src/config/defaults.js' +import { DocBridgeConfigV1Schema } from '../src/config/schema.js' +import { createRegistryAgentAdapter, DEFAULT_REGISTRY_AGENT_ID, loadRegistryAgentMetadata, persistRegistryAgentProposal } from '../src/agents/registry-adapter.js' +import { contentHashForArtifactV1 } from '../src/index-builder/content-hash.js' +import { DiscoverySnapshotV1Schema, ReconciliationReportV1Schema } from '../src/schemas/knowledge.js' + +const config = (enabled = true) => applyConfigDefaults(DocBridgeConfigV1Schema.parse({ schemaVersion: 1, corpus: { agent: { root: 'docs' } }, intelligence: { registry: { enabled } } })) +const fixture = () => { + const snapshot = DiscoverySnapshotV1Schema.parse({ type: 'discovery-snapshot', schemaVersion: 1, contentHash: 'a'.repeat(64), contentHashAlgo: 'sha256-normalized-v1', project: { name: 'fixture' }, sourceRevision: 'revision-1', sourceRevisionKind: 'content', configurationHash: 'b'.repeat(64), pipelineVersion: '1.0.0', analyzerVersions: { 'js-ts': '1.0.0' }, entities: [], relations: [], coverage: [] }) + const report = ReconciliationReportV1Schema.parse({ type: 'reconciliation-report', schemaVersion: 1, contentHash: 'c'.repeat(64), contentHashAlgo: 'sha256-normalized-v1', project: snapshot.project, sourceRevision: snapshot.sourceRevision, sourceRevisionKind: snapshot.sourceRevisionKind, configurationHash: snapshot.configurationHash, pipelineVersion: snapshot.pipelineVersion, analyzerVersions: snapshot.analyzerVersions, snapshotHash: snapshot.contentHash, diagnostics: [{ id: 'd1', code: 'TEST', status: 'undocumented', severity: 'warn', message: 'token=should-not-escape', evidence: [{ source: 'documentation', path: 'docs/a.md', context: 'token=hidden' }] }], summary: { entityCount: 0, relationCount: 0, diagnosticCount: 1 } }) + return { snapshot, report } +} + +describe('AgentsKit Registry adapter', () => { + it('requires an installed source-owned Registry agent and returns typed proposals', async () => { + const root = mkdtempSync(join(tmpdir(), 'doc-bridge-agent-')) + const agentPath = join(root, 'agents', DEFAULT_REGISTRY_AGENT_ID) + mkdirSync(agentPath, { recursive: true }) + writeFileSync(join(agentPath, 'agent.json'), JSON.stringify({ id: DEFAULT_REGISTRY_AGENT_ID, version: '1.0.0', provider: 'agentskit', model: 'fixture', capabilities: ['snapshot.read', 'evidence.read', 'proposal.write'] })) + const { snapshot, report } = fixture() + const adapter = createRegistryAgentAdapter(root, config(), (context) => { + expect(Object.isFrozen(context)).toBe(true) + expect(JSON.stringify(context)).not.toContain('token=hidden') + const proposal = { type: 'agent-proposal' as const, schemaVersion: 1 as const, contentHash: '0'.repeat(64), contentHashAlgo: 'sha256-normalized-v1' as const, project: snapshot.project, sourceRevision: snapshot.sourceRevision, sourceRevisionKind: snapshot.sourceRevisionKind, configurationHash: snapshot.configurationHash, pipelineVersion: '1.0.0', analyzerVersions: { agent: '1.0.0' }, proposalId: 'p1', baseSnapshotHash: snapshot.contentHash, baseReportHash: report.contentHash, relatedDiagnosticIds: ['d1'], rationale: 'Review the finding.', confidence: 0.8, evidence: [], intendedChanges: ['Update the documentation.'], origin: { kind: 'registry-agent' as const, id: DEFAULT_REGISTRY_AGENT_ID, version: '1.0.0', capabilities: ['proposal.write'] }, checks: ['pnpm test'] } + return { ...proposal, contentHash: contentHashForArtifactV1(proposal) } + }) + const proposal = await adapter.run(snapshot, report) + expect(proposal.origin.id).toBe(DEFAULT_REGISTRY_AGENT_ID) + const saved = persistRegistryAgentProposal(join(root, '.doc-bridge', 'workflow'), proposal) + expect(readFileSync(saved, 'utf8')).toContain(DEFAULT_REGISTRY_AGENT_ID) + }) + + it('fails closed when disabled, unavailable or replaced by another origin', () => { + const root = mkdtempSync(join(tmpdir(), 'doc-bridge-agent-missing-')) + expect(() => createRegistryAgentAdapter(root, config(false), () => ({}))).toThrow('disabled') + expect(() => loadRegistryAgentMetadata(root, config())).toThrow('not installed') + }) +})