ops·Registry checks passed · v1.0.0

Knowledge Promoter Agent

Turns curated private notes into public documentation PRs — classify, sanitize to vendor-neutral, adversarial leak-gate, then open one draft PR. Never merges; a human reviews.

documentationknowledgeokfpipelineleak-preventionhuman-in-the-loop

Install

npx agentskit add knowledge-promoter

Quick start

import { openai } from '@agentskit/adapters'import { createKnowledgePromoterAgent } from './agents/knowledge-promoter/agent'const agent = createKnowledgePromoterAgent({  adapter: openai({    apiKey: process.env.OPENAI_API_KEY!,    model: 'gpt-4o',  }),})const result = await agent.run('Describe your task here')console.log(result.content)

Example

A real usage example maintained with this agent.

import { anthropic } from '@agentskit/adapters'import { createKnowledgePromoterAgent } from './agents/knowledge-promoter/agent'const agent = createKnowledgePromoterAgent({  adapter: anthropic({ apiKey: process.env.ANTHROPIC_API_KEY!, model: 'claude-opus-4-8' }),  siteMapUrl: 'https://docs.example.com/llms.txt',  houseStyle: '`# Title` → `## TL;DR (human)` → `## For agents` → `### See also`',  denylist: [/\bACME-\d+\b/, /\binternal-[a-z]+\b/],  publish: async (docs) => {    // write each doc into a checkout, then `gh pr create --draft`. Return { pr, url }.    return { pr: 123, url: 'https://github.com/org/docs/pull/123' }  },})const { outcomes, pr } = await agent.run([  { id: 'n1', title: 'Equalize columns by layout, not text', lesson: '…raw lesson, internal context allowed…' },])

Extend it

Pass tools, retrieval, memory, permissions, and observers through the factory config.

const agent = createKnowledgePromoterAgent({  adapter,  tools,  retriever,  memory,  onConfirm: (call) => approve(call),  observers: [tracer],})
View agent factory source
import type { AdapterFactory, ChatMemory, Observer, ToolCall, ToolDefinition } from '@agentskit/core'import { createRuntime } from '@agentskit/runtime'import { defineZodTool } from '@agentskit/tools'import { z } from 'zod'import { zodToJsonSchema } from 'zod-to-json-schema'import type { JSONSchema7 } from 'json-schema'import { classifier, leakAuditor, sanitizer } from './skills'/** * Knowledge Promoter — turns curated private notes into PUBLIC documentation PRs, * safely. Pipeline: classify → sanitize → leak-gate → publish (one draft PR). The * sanitizer (writer) and leak auditor are separate agents in separate runtimes, so * the writer never grades its own homework. The leak gate is DETERMINISTIC: a regex * denylist plus the adversarial auditor, both run by orchestration code — never a * step the model can decide to skip. It never merges; a human reviews the draft PR. * * Reusable for any "private notes → public docs" flow: inject your site map, house * style, denylist, and a `publish` function. * * ```ts * import { anthropic } from '@agentskit/adapters' * const agent = createKnowledgePromoterAgent({ *   adapter: anthropic({ apiKey: process.env.ANTHROPIC_API_KEY!, model: 'claude-opus-4-8' }), *   siteMapUrl: 'https://docs.example.com/llms.txt', *   houseStyle: '# Title → ## TL;DR → ## For agents → ### See also', *   denylist: [/\bACME-\d+\b/, /\binternal-[a-z]+\b/], *   publish: async (drafts) => openDraftPrSomehow(drafts), * }) * const report = await agent.run(candidates) * ``` */export interface Candidate {  /** Stable id for ledger/dedup (caller-owned). */  id: string  title: string  /** Raw lesson text — may contain internal context; the pipeline strips it. */  lesson: string}export interface PreparedDoc {  candidate: Candidate  category: string | null  shape: 'new' | 'enrich'  targetDoc: string | null  /** Assembled markdown with frontmatter, ready to write. */  markdown: string  title: string  droppedForGenerality: string[]}export interface PromoteOutcome {  candidate: Candidate  status: 'promoted' | 'rejected' | 'blocked'  /** For rejected/blocked: why. For promoted: the doc that will be opened. */  detail: string}export type PromoteStage = 'classify' | 'sanitize' | 'leak-gate' | 'publish'/** * Progress is emitted through the standard observer channel as `AgentEvent`s of * `{ type: 'progress', label, status, detail?, durationMs? }`. Pass any Observer * in `observers` (e.g. `createProgressObserver()` from `@agentskit/ink`) to render * it live — no bespoke callback; one channel for runtime + domain events. */export interface KnowledgePromoterConfig {  /** Any AgentsKit adapter (anthropic, openai, gemini, …). */  adapter: AdapterFactory  /** URL of the target docs' machine-readable site map (e.g. /llms.txt), for dedup. */  siteMapUrl: string  /** House-style spec injected verbatim into the sanitizer (frontmatter + section order). */  houseStyle: string  /** Regex denylist — the deterministic first leak layer. Hits = hard block. */  denylist: RegExp[]  /** Opens ONE draft PR with all cleared docs and returns its number/URL. Never merges. */  publish: (docs: PreparedDoc[]) => Promise<{ pr: number | string; url?: string }>  /** Optional: conversation memory, observers, HITL confirm, step cap. */  memory?: ChatMemory  observers?: Observer[]  /** Per-tool-call confirmation gate (HITL / RBAC) — passed to the runtime. */  onConfirm?: (toolCall: ToolCall) => boolean | Promise<boolean>  /** Independent leak-audit votes; the gate blocks on a MAJORITY of "block". Default 3. */  auditVotes?: number  maxSteps?: number}const Classification = z.object({  isGeneralizable: z.boolean(),  category: z.string().nullable(),  shape: z.enum(['new', 'enrich']),  targetDoc: z.string().nullable(),  alreadyCovered: z.boolean(),  reason: z.string(),})const Sanitized = z.object({  title: z.string(),  description: z.string(),  type: z.string(),  body: z.string(),  droppedForGenerality: z.array(z.string()),})const LeakVerdict = z.object({  verdict: z.enum(['clean', 'block']),  identifiers: z.array(z.string()),  explanation: z.string(),})const toJson = (s: z.ZodTypeAny): JSONSchema7 => zodToJsonSchema(s) as JSONSchema7export function createKnowledgePromoterAgent(config: KnowledgePromoterConfig) {  const maxSteps = config.maxSteps ?? 4  // submit_* tools: the agent's only job each stage is to call one of these once.  // execute() just acknowledges — the structured payload is read back from toolCalls.  const submit = (name: string, schema: z.ZodTypeAny): ToolDefinition =>    defineZodTool({      name,      description: `Submit the ${name.replace('submit_', '').replace('_', ' ')} result. Call exactly once.`,      schema,      toJsonSchema: toJson,      async execute() {        return 'recorded'      },    }) as ToolDefinition  // One focused runtime per structured stage → reads the validated payload back.  async function runStructured<T extends z.ZodTypeAny>(opts: {    skill: typeof classifier    task: string    tool: ToolDefinition    schema: T  }): Promise<z.infer<T>> {    const runtime = createRuntime({      adapter: config.adapter,      tools: [opts.tool],      memory: config.memory,      observers: config.observers,      onConfirm: config.onConfirm,      maxSteps,    })    const result = await runtime.run(opts.task, { skill: opts.skill })    const call = result.toolCalls.find((c) => c.name === opts.tool.name)    if (!call) throw new Error(`${opts.skill.name} did not submit a result`)    return opts.schema.parse(call.args)  }  async function fetchSiteMap(): Promise<string> {    const res = await fetch(config.siteMapUrl)    if (!res.ok) throw new Error(`site map fetch failed: ${res.status}`)    return res.text()  }  const auditVotes = Math.max(1, config.auditVotes ?? 3)  async function leakGate(text: string): Promise<{ ok: boolean; hits: string[]; note?: string }> {    // Layer 1 — regex denylist (deterministic, no tokens). Any hit hard-blocks.    const hits = [...new Set(config.denylist.flatMap((re) => text.match(new RegExp(re, 'g')) ?? []))]    if (hits.length) return { ok: false, hits }    // Layer 2 — N independent adversarial auditors (separate runtimes, in parallel).    // Block on a MAJORITY of "block" verdicts; each auditor defaults to "block" when unsure.    const verdicts = await Promise.all(      Array.from({ length: auditVotes }, () =>        runStructured({ skill: leakAuditor, task: text, tool: submit('submit_leak_verdict', LeakVerdict), schema: LeakVerdict }),      ),    )    const blocked = verdicts.filter((v) => v.verdict !== 'clean')    if (blocked.length * 2 >= auditVotes) {      return { ok: false, hits: [...new Set(blocked.flatMap((v) => v.identifiers))], note: blocked[0]?.explanation }    }    return { ok: true, hits: [] }  }  // Emit a domain-progress AgentEvent through the standard observer channel.  const emit = (label: PromoteStage, status: 'start' | 'ok' | 'skip' | 'error', detail?: string, durationMs?: number) => {    for (const o of config.observers ?? []) void o.on({ type: 'progress', label, status, detail, durationMs })  }  // Times a stage, emitting start → ok progress around it.  async function stage<T>(name: PromoteStage, fn: () => Promise<T>, label: (r: T) => string): Promise<T> {    emit(name, 'start')    const t0 = Date.now()    const result = await fn()    emit(name, 'ok', label(result), Date.now() - t0)    return result  }  async function run(candidates: Candidate[]): Promise<{ outcomes: PromoteOutcome[]; pr?: { pr: number | string; url?: string } }> {    if (candidates.length === 0) return { outcomes: [] }    const siteMap = await fetchSiteMap()    const outcomes: PromoteOutcome[] = []    const docs: PreparedDoc[] = []    for (const c of candidates) {      // 1 — classify (always re-decides; dedup against the live site map).      const cls = await stage('classify',        () => runStructured({ skill: classifier, task: `SITE MAP:\n${siteMap}\n\nCANDIDATE LESSON (title: ${c.title}):\n${c.lesson}`, tool: submit('submit_classification', Classification), schema: Classification }),        (r) => (r.isGeneralizable && !r.alreadyCovered ? `${c.title} → ${r.category} · ${r.shape}` : 'skip'))      if (!cls.isGeneralizable || cls.alreadyCovered) {        const detail = cls.alreadyCovered ? `duplicate: ${cls.reason}` : `not generalizable: ${cls.reason}`        emit('classify', 'skip', `${c.title}: ${detail}`)        outcomes.push({ candidate: c, status: 'rejected', detail })        continue      }      // 2 — sanitize into the target house style.      const sani = await stage('sanitize',        () => runStructured({ skill: sanitizer, task: `CATEGORY: ${cls.category}\nSHAPE: ${cls.shape}${cls.targetDoc ? ` (${cls.targetDoc})` : ''}\n\nHOUSE STYLE:\n${config.houseStyle}\n\nLESSON:\n${c.lesson}`, tool: submit('submit_sanitized', Sanitized), schema: Sanitized }),        (r) => `"${r.title}"`)      // 3 — leak gate (regex + N adversarial auditors). BOTH layers must pass.      const markdown = `---\ntype: ${sani.type}\ntitle: '${sani.title.replace(/'/g, "''")}'\ndescription: '${sani.description.replace(/'/g, "''")}'\n---\n\n${sani.body}\n`      emit('leak-gate', 'start')      const t0 = Date.now()      const gate = await leakGate(`${sani.title}\n${sani.description}\n${sani.body}`)      if (!gate.ok) {        emit('leak-gate', 'error', `blocked: ${gate.hits.join(', ')}`, Date.now() - t0)        outcomes.push({ candidate: c, status: 'blocked', detail: `leak-gate: ${gate.hits.join(', ')}${gate.note ? ` — ${gate.note}` : ''}` })        continue      }      emit('leak-gate', 'ok', `clean (${auditVotes} votes)`, Date.now() - t0)      docs.push({ candidate: c, category: cls.category, shape: cls.shape, targetDoc: cls.targetDoc, markdown, title: sani.title, droppedForGenerality: sani.droppedForGenerality })      outcomes.push({ candidate: c, status: 'promoted', detail: `${cls.shape} ${cls.category}/${sani.title}` })    }    // 4 — one batched draft PR (human gate). Never merges.    if (docs.length === 0) return { outcomes }    emit('publish', 'start', `${docs.length} doc(s)`)    const t0 = Date.now()    const pr = await config.publish(docs)    emit('publish', 'ok', `PR ${pr.pr}`, Date.now() - t0)    return { outcomes, pr }  }  return {    /** Stable name for orchestration (supervisor / swarm / A2A). */    name: 'knowledge-promoter',    run,    /** AgentHandle: accepts a JSON array of candidates, returns a text summary. */    asHandle() {      return {        name: 'knowledge-promoter',        run: async (task: string) => {          const candidates = JSON.parse(task) as Candidate[]          const { outcomes, pr } = await run(candidates)          return JSON.stringify({ outcomes, pr }, null, 2)        },      }    },  }}// fetchUrl is re-exported for consumers who prefer the AgentsKit tool over global fetch.export { fetchUrl } from '@agentskit/tools'

Was this agent useful?

Your response helps us prioritize agent quality.

Keep exploring

Related agents

View category