Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions src/agents/registry-adapter.ts
Original file line number Diff line number Diff line change
@@ -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<typeof RegistryAgentMetadataSchema> & { 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> | unknown

export type RegistryAgentAdapter = {
readonly metadata: RegistryAgentMetadata
readonly run: (snapshot: DiscoverySnapshotV1, report: ReconciliationReportV1, evidence?: readonly RegistryAgentContext['evidence'][number][]) => Promise<AgentProposalV1>
}

const deepFreeze = <T>(value: T): T => {
if (value && typeof value === 'object' && !Object.isFrozen(value)) {
Object.freeze(value)
for (const child of Object.values(value as Record<string, unknown>)) deepFreeze(child)
}
return value
}

const registryConfig = (config: DocBridgeConfigV1) => config.intelligence?.registry

export const loadRegistryAgentRunner = async (root: string, config: DocBridgeConfigV1): Promise<RegistryAgentRunner> => {
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
}
23 changes: 23 additions & 0 deletions src/cli/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand All @@ -63,6 +64,7 @@ type Command =
| 'check'
| 'map'
| 'fix'
| 'suggest'
| 'index'
| 'gate'
| 'rules'
Expand All @@ -89,6 +91,7 @@ Core (no API key):
ak-docs scan | reconcile | check | map [--text|--json]
ak-docs fix propose links|normalize <artifact> [--output <file>]
ak-docs fix approve|apply <proposal.json> [--by <name>]
ak-docs suggest [--json|--text] run the configured local Registry agent
ak-docs query [package|ownership|intent|change] <id> [--agent] [--text]
ak-docs search <term> [--agent] [--text]
ak-docs list <packages|intents|changes|knowledge> [--text]
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -616,6 +620,24 @@ const runFixCommand = (argv: readonly string[], positional: readonly string[], c
}
}

const runSuggestCommand = async (flags: ReadonlySet<string>, configPath: string | undefined): Promise<number> => {
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 })
Expand Down Expand Up @@ -862,6 +884,7 @@ export const runCli = (argv: readonly string[]): number | undefined | Promise<nu
}

if (command === 'fix') return runFixCommand(argv, positional, configPath)
if (command === 'suggest') return runSuggestCommand(flags, configPath)

if (command === 'init') {
const root = process.cwd()
Expand Down
9 changes: 9 additions & 0 deletions src/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,15 @@ export const IntelligenceConfigSchema = z
.optional(),
runtime: z.enum(['agentskit', 'custom']).optional(),
runtimeModule: z.string().min(1).max(512).optional(),
registry: z
.object({
enabled: z.boolean().optional(),
agentId: z.string().min(1).max(256).optional(),
agentRoot: z.string().min(1).max(512).optional(),
runnerModule: z.string().min(1).max(512).optional(),
})
.strict()
.optional(),
})
.strict()

Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ export {
export { buildDocBridgeIndex, type BuildIndexOptions, type BuildIndexResult } from './index-builder/build-index.js'
export { discoverRepository, type DiscoveryOptions } from './discovery/repository.js'
export { containedPath, DEFAULT_SAFETY_EXCLUDES, redactSecrets, redactValue, safeWalkFiles, type SafeWalkOptions, type SafeWalkResult } from './safety/repository.js'
export { DEFAULT_REGISTRY_AGENT_ID, createRegistryAgentAdapter, loadRegistryAgentMetadata, loadRegistryAgentRunner, persistRegistryAgentProposal, type RegistryAgentAdapter, type RegistryAgentContext, type RegistryAgentMetadata, type RegistryAgentRunner } from './agents/registry-adapter.js'
export {
applyDocumentationDeclarations,
parseDocumentationDeclarations,
Expand Down
15 changes: 13 additions & 2 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { PACKAGE_VERSION } from '../version.js'
import { loadWorkflowManifest, loadWorkflowStepOutput } from '../workflow/engine.js'
import { parseDiscoverySnapshot, parseReconciliationReport } from '../validate.js'
import { applyFixProposal, approveFixProposal, createArtifactNormalizationProposal, createMarkdownLinkFixProposal } from '../fixes/proposals.js'
import { createRegistryAgentAdapter, loadRegistryAgentRunner, persistRegistryAgentProposal } from '../agents/registry-adapter.js'
import { sha256NormalizedV1 } from '../index-builder/content-hash.js'
import { discoverRepository } from '../discovery/repository.js'
import { FixProposalV1Schema, type DiscoverySnapshotV1, type ReconciliationReportV1, type FixProposalV1 } from '../schemas/knowledge.js'
Expand Down Expand Up @@ -145,7 +146,7 @@ export const MCP_TOOLS = [
name: 'docbridge.proposals',
title: 'Read or approve proposals',
description: 'Create, inspect, approve and apply deterministic proposals through the shared human-gated workflow.',
inputSchema: { type: 'object', properties: { action: { type: 'string', enum: ['list', 'propose-links', 'propose-normalize', 'approve', 'apply'] }, proposalHash: { type: 'string' }, artifactPath: { type: 'string' }, approvedBy: { type: 'string' }, proposal: { type: 'object' } } },
inputSchema: { type: 'object', properties: { action: { type: 'string', enum: ['list', 'propose-links', 'propose-normalize', 'suggest', 'approve', 'apply'] }, proposalHash: { type: 'string' }, artifactPath: { type: 'string' }, approvedBy: { type: 'string' }, proposal: { type: 'object' } } },
},
] as const

Expand Down Expand Up @@ -177,7 +178,7 @@ const DocGetArgsSchema = z
const WorkflowRunArgsSchema = z.object({ runId: z.string().min(1).optional() })
const DiagnosticsArgsSchema = z.object({ status: z.string().min(1).optional(), severity: z.string().min(1).optional() })
const RelationsArgsSchema = z.object({ kind: z.string().min(1).optional(), limit: z.number().int().positive().max(500).optional() })
const ProposalsArgsSchema = z.object({ action: z.enum(['list', 'propose-links', 'propose-normalize', 'approve', 'apply']).optional(), proposalHash: z.string().min(1).optional(), artifactPath: z.string().min(1).optional(), approvedBy: z.string().min(1).optional(), proposal: z.unknown().optional() })
const ProposalsArgsSchema = z.object({ action: z.enum(['list', 'propose-links', 'propose-normalize', 'suggest', 'approve', 'apply']).optional(), proposalHash: z.string().min(1).optional(), artifactPath: z.string().min(1).optional(), approvedBy: z.string().min(1).optional(), proposal: z.unknown().optional() })

const parseToolArgs = <T>(tool: string, schema: z.ZodType<T>, value: unknown): T => {
try {
Expand Down Expand Up @@ -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') {
Expand Down
43 changes: 43 additions & 0 deletions tests/registry-adapter.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
Loading