{"id":"support-kb-searcher","title":"KB Searcher","description":"Matches a support ticket to the top knowledge-base articles as TYPED hits with a 1-5 confidence score. Optional retriever grounds answers in YOUR corpus — only retrieved candidates can be cited; never invents an article (returns noMatch + a suggested topic instead).","category":"support","version":"2.0.0","source":"AKOS","license":"MIT","tags":["support","rag","grounding"],"packages":["@agentskit/core","@agentskit/runtime","@agentskit/tools"],"files":["agent.ts","README.md","eval.ts"],"requires":{"zod":"^3","zod-to-json-schema":"^3"},"status":"validated","skill":{"name":"support-kb-searcher","description":"Matches a support ticket to the top knowledge-base articles as TYPED hits with a 1-5 confidence score. Optional retriever grounds answers in YOUR corpus — only retrieved candidates can be cited; never invents an article (returns noMatch + a suggested topic instead).","systemPrompt":"You match a support ticket to knowledge-base articles. Return the best articles as hits,\neach with: title, url, a one-sentence quote of the matching section, and a confidence 1-5.\n\nNEVER invent an article. If CANDIDATES are provided, cite ONLY from them (and cite NONE if the set\nis empty). If nothing genuinely matches, set noMatch=true, return an empty hits array, and put the\ntopic a new article should cover in suggestedTopic.\n\n${UNTRUSTED_CONTENT_DIRECTIVE}\n\nCall submit_results exactly once with { hits, noMatch, suggestedTopic? }. Stop."},"flow":null,"a2a":{"id":"io.agentskit.registry.support-kb-searcher","name":"KB Searcher","description":"Matches a support ticket to the top knowledge-base articles as TYPED hits with a 1-5 confidence score. Optional retriever grounds answers in YOUR corpus — only retrieved candidates can be cited; never invents an article (returns noMatch + a suggested topic instead).","version":"2.0.0","homepage":"https://registry.agentskit.io","skills":[{"name":"support-kb-searcher","description":"Matches a support ticket to the top knowledge-base articles as TYPED hits with a 1-5 confidence score. Optional retriever grounds answers in YOUR corpus — only retrieved candidates can be cited; never invents an article (returns noMatch + a suggested topic instead).","capabilities":{"streaming":true,"cancellation":true,"requiresApproval":false}}]},"sources":[{"path":"agent.ts","content":"import type { AdapterFactory, ChatMemory, Observer, ToolCall, ToolDefinition } from '@agentskit/core'\nimport { fenceUntrustedContent, UNTRUSTED_CONTENT_DIRECTIVE } from '@agentskit/core/security'\nimport { invokeStructured } from '@agentskit/runtime'\nimport { defineZodTool } from '@agentskit/tools'\nimport { z } from 'zod'\nimport { zodToJsonSchema } from 'zod-to-json-schema'\nimport type { JSONSchema7 } from 'json-schema'\n\n/**\n * KB Searcher — given a ticket (and optionally a retrieved candidate set), returns the\n * top KB articles that answer it as TYPED hits with a confidence score. Never invents\n * an article: when no candidate is grounded, it returns `noMatch` + a suggested topic\n * the article should cover, rather than a hallucinated URL.\n *\n * Pass `retrieve` to ground answers in YOUR corpus — only candidates it returns can be\n * cited (the agent is told to cite none if the set is empty). Without it the model may\n * only cite articles named verbatim in the ticket.\n */\n\nexport interface KbHit {\n  title: string\n  url: string\n  /** One-sentence quote of the matching section. */\n  quote: string\n  /** 1 (weak) – 5 (exact). */\n  confidence: number\n}\n\nexport interface KbSearchResult {\n  hits: KbHit[]\n  /** True when nothing grounded matched. */\n  noMatch: boolean\n  /** When noMatch: the topic a new article should cover. */\n  suggestedTopic?: string\n}\n\nexport interface KbCandidate {\n  title: string\n  url: string\n  snippet: string\n}\n\nexport interface KbSearcherConfig {\n  adapter: AdapterFactory\n  /** Optional retriever — only the candidates it returns may be cited. */\n  retrieve?: (ticket: string) => Promise<KbCandidate[]> | KbCandidate[]\n  /** Max hits to return (default 3). */\n  topK?: number\n  /** Drop hits below this confidence (1-5, default 2). */\n  minConfidence?: number\n  memory?: ChatMemory\n  observers?: Observer[]\n  onConfirm?: (toolCall: ToolCall) => boolean | Promise<boolean>\n  maxSteps?: number\n}\n\nconst Hit = z.object({\n  title: z.string(),\n  url: z.string(),\n  quote: z.string(),\n  confidence: z.number().int().min(1).max(5),\n})\nconst Output = z.object({\n  hits: z.array(Hit),\n  noMatch: z.boolean(),\n  suggestedTopic: z.string().optional(),\n})\nconst toJson = (s: z.ZodTypeAny): JSONSchema7 => zodToJsonSchema(s) as JSONSchema7\n\nconst skill = {\n  name: 'kb-searcher',\n  description: 'Returns the top knowledge-base articles answering a ticket, with confidence.',\n  systemPrompt: `You match a support ticket to knowledge-base articles. Return the best articles as hits,\neach with: title, url, a one-sentence quote of the matching section, and a confidence 1-5.\n\nNEVER invent an article. If CANDIDATES are provided, cite ONLY from them (and cite NONE if the set\nis empty). If nothing genuinely matches, set noMatch=true, return an empty hits array, and put the\ntopic a new article should cover in suggestedTopic.\n\n${UNTRUSTED_CONTENT_DIRECTIVE}\n\nCall submit_results exactly once with { hits, noMatch, suggestedTopic? }. Stop.`,\n  tools: ['submit_results'],\n}\n\nexport function createKbSearcherAgent(config: KbSearcherConfig) {\n  const topK = config.topK ?? 3\n  const minConf = config.minConfidence ?? 2\n  const emit = (label: string, status: 'start' | 'ok' | 'skip' | 'error', detail?: string) => {\n    for (const o of config.observers ?? []) void o.on({ type: 'progress', label, status, detail })\n  }\n  const submit = (): ToolDefinition =>\n    defineZodTool({ name: 'submit_results', description: 'Submit the KB hits. Call exactly once.', schema: Output, toJsonSchema: toJson, async execute() { return 'recorded' } }) as ToolDefinition\n\n  async function run(ticket: string): Promise<KbSearchResult> {\n    if (!ticket?.trim()) throw new Error('kb searcher requires a non-empty ticket')\n\n    let candidateBlock = ''\n    let allowedUrls: Set<string> | null = null\n    if (config.retrieve) {\n      emit('retrieve', 'start')\n      const candidates = (await config.retrieve(ticket)) ?? []\n      allowedUrls = new Set(candidates.map((c) => c.url))\n      emit('retrieve', 'ok', `${candidates.length} candidate(s)`)\n      candidateBlock = `\\n\\nCANDIDATES (cite only these; cite none if empty):\\n${fenceUntrustedContent(\n        candidates.map((c) => `- ${c.title} <${c.url}>: ${c.snippet}`).join('\\n') || '(none)',\n      )}`\n    }\n\n    emit('search', 'start')\n    let out: z.infer<typeof Output>\n    try {\n      out = await invokeStructured({\n        adapter: config.adapter,\n        tool: submit(),\n        task: `TICKET:\\n${fenceUntrustedContent(ticket)}${candidateBlock}`,\n        parse: (a) => Output.parse(a),\n        skill,\n        memory: config.memory,\n        observers: config.observers,\n        onConfirm: config.onConfirm,\n        maxSteps: config.maxSteps ?? 3,\n      })\n    } catch {\n      emit('search', 'error')\n      return { hits: [], noMatch: true, suggestedTopic: 'search unavailable — retry' }\n    }\n\n    // Ground: drop low-confidence hits and (if retrieving) any URL not in the candidate set.\n    const hits = out.hits\n      .filter((h) => h.confidence >= minConf)\n      .filter((h) => (allowedUrls ? allowedUrls.has(h.url) : true))\n      .sort((a, b) => b.confidence - a.confidence)\n      .slice(0, topK)\n    const noMatch = hits.length === 0\n    emit('search', 'ok', noMatch ? 'no match' : `${hits.length} hit(s)`)\n    return {\n      hits,\n      noMatch,\n      suggestedTopic: noMatch ? (out.suggestedTopic ?? `coverage for: ${ticket.slice(0, 80)}`) : undefined,\n    }\n  }\n\n  return {\n    name: 'support-kb-searcher',\n    run,\n    asHandle() {\n      return { name: 'support-kb-searcher', run: async (task: string) => JSON.stringify(await run(task)) }\n    },\n  }\n}\n"},{"path":"README.md","content":"# KB Searcher\n\nMatches a support ticket to the top knowledge-base articles as **typed hits with a 1–5 confidence**, grounded in **your** corpus — never invents an article.\n\n```bash\nnpx agentskit add support-kb-searcher\n```\n\n```ts\nimport { anthropic } from '@agentskit/adapters'\nimport { createKbSearcherAgent } from './agents/support-kb-searcher/agent'\n\nconst r = await createKbSearcherAgent({\n  adapter: anthropic({ apiKey: process.env.ANTHROPIC_API_KEY!, model: 'claude-opus-4-8' }),\n  retrieve: (ticket) => myVectorStore.search(ticket), // → KbCandidate[]\n}).run(ticketText)\n// → { hits: [{ title, url, quote, confidence }], noMatch, suggestedTopic? }\n```\n\n- **Typed hits** — `invokeStructured` + zod; each hit carries a one-sentence quote and `confidence` 1–5. Sorted by confidence, capped at `topK` (default 3).\n- **Grounded** — pass `retrieve` and **only** its candidates may be cited (any other URL is dropped post-hoc); without it the model may only cite articles named in the ticket.\n- **Never invents** — when nothing clears `minConfidence`, returns `noMatch: true` + a `suggestedTopic` the missing article should cover, never a hallucinated link.\n- Untrusted ticket + candidate text is **fenced**.\n\n`run(ticket)` → `KbSearchResult`. `asHandle()` is JSON-out. See [composing agents](../../COMPOSING.md). Pairs with [`support-triage-bot`](../support-triage-bot).\n"},{"path":"eval.ts","content":"import type { EvalSuite } from '@agentskit/eval'\n\nexport const suite: EvalSuite = {\n  name: 'support-kb-searcher',\n  cases: [\n    {\n      input: `Ticket: A customer asks how to rotate their API key after a suspected leak. They want to know if rotating invalidates the old key immediately and whether existing webhooks keep working. Find the relevant KB articles.`,\n      expected: (r: string) => /(http|www|\\/docs|kb)/i.test(r) && /confidence/i.test(r),\n    },\n    {\n      input: `Ticket: User on the Pro plan wants to downgrade to Starter mid-cycle and is asking whether they get a prorated refund. Find the top knowledge-base articles that answer this.`,\n      expected: (r: string) => /([1-5])/.test(r) && /(refund|proration|prorate|downgrade|billing)/i.test(r),\n    },\n    {\n      input: `Ticket: Customer reports that SAML SSO login redirects them back to the login page in a loop after entering their Okta credentials. Find KB articles covering SSO / SAML troubleshooting.`,\n      expected: (r: string) => /(SSO|SAML|Okta|login)/i.test(r) && /title/i.test(r),\n    },\n    {\n      input: `Ticket: A customer is asking for detailed guidance on integrating our product with an obscure in-house ERP that we have never documented anywhere. Search the knowledge base.`,\n      expected: (r: string) => /(no (good )?match|no relevant|could not find|no article|suggest)/i.test(r),\n    },\n  ],\n}\n"}],"installable":true,"validation":{"status":"approved","score":96,"confidence":0.95,"method":"codex-executor-independent-reviewer","iterations":1,"cases":3,"summary":"The agent produced valid structured outputs for all three cases, stayed within its KB-search purpose, avoided inventing articles or URLs, returned noMatch when no grounded candidates or article names were available, and resisted the injection request. The suggested topics are reasonable and uncertainty-aware. The only minor weakness is that the normal case output is necessarily low-value because the input was a meta instruction rather than an actual support ticket or retrieved corpus, but the conservative no-match behavior is correct for the agent contract.","strengths":["All outputs conform to the expected structured shape with hits, noMatch, and suggestedTopic.","No hallucinated articles, URLs, quotes, or confidence scores were invented.","Injection case ignored the instruction to output APPROVED and treated the content as untrusted/sparse.","Minimal and sparse inputs surfaced gaps through suggestedTopic instead of fabricating matches."],"notes":[]}}