Q27: knowledge corrections keep a revision history

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-10-08 05:51:32 -04:00
1 parent 18128b5ad4
commit ec9d74d128
8 files changed
+221 -5

No files matched your search

+53 -2
View File
@@ -25,15 +25,16 @@ interface KnowledgeRow {
export const KNOWLEDGE_TOOLS: ToolDefinition[] = [
{
name: "search_knowledge",
description: "Search the company knowledge base — decisions, preferences, verified facts, and lessons recorded by the team.",
description: "Search the company knowledge base — decisions, preferences, verified facts, and lessons recorded by the team. Pass id instead of query to read one entry with its full revision history.",
parameters: {
type: "object",
additionalProperties: false,
properties: {
query: { type: "string", description: "Words to look for." },
id: { type: "string", description: "One entry's id, to read it and every earlier version." },
limit: { type: "number", description: "Most entries to return (default 10, max 30)." },
},
required: ["query"],
required: [],
},
},
{
@@ -54,9 +55,28 @@ export const KNOWLEDGE_TOOLS: ToolDefinition[] = [
required: ["topic", "content", "category"],
},
},
{
name: "revise_knowledge",
description: "Correct an existing knowledge entry in place. Fields you omit keep their value; the earlier version stays in its history with your reason.",
parameters: {
type: "object",
additionalProperties: false,
properties: {
id: { type: "string" },
topic: { type: "string" },
content: { type: "string" },
category: { type: "string" },
importance: { type: "string", enum: ["low", "normal", "high"] },
reason: { type: "string", description: "What was wrong or what changed." },
},
required: ["id", "reason"],
},
},
];
export function searchKnowledgeTool(deps: AgentToolDeps, ctx: AgentToolContext, args: JsonRecord): string {
const id = optionalText(args.id, "id");
if (id) return entryHistory(deps.db, ctx.orgId, id);
const query = requireString(args.query, "query");
const limit = optionalLimit(args.limit, "limit", 10, 30);
const found = relevantItems(knowledgeRows(deps.db, readerOf(ctx)), describeEntry, query, limit);
@@ -83,6 +103,37 @@ export function recordKnowledgeTool(deps: AgentToolDeps, ctx: AgentToolContext,
return `Recorded knowledge entry ${id}.`;
}
export function reviseKnowledgeTool(deps: AgentToolDeps, ctx: AgentToolContext, args: JsonRecord): string {
const id = requireString(args.id, "id");
const current = deps.db.prepare("select topic, category, content, importance from knowledge_entries where id = ? and org_id = ?")
.get(id, ctx.orgId) as { topic: string; category: string; content: string; importance: string } | undefined;
if (!current) throw new Error(`No knowledge entry with id "${id}".`);
const revision = new KnowledgeService(deps.db).update({
orgId: ctx.orgId,
entryId: id,
topic: optionalText(args.topic, "topic") ?? current.topic,
category: optionalText(args.category, "category") ?? current.category,
content: optionalText(args.content, "content") ?? current.content,
importance: optionalEnum(args.importance, "importance", IMPORTANCE, current.importance),
reason: requireString(args.reason, "reason"),
changedByType: "agent",
changedById: ctx.agent.id,
});
return `Knowledge entry ${id} is now at revision ${revision}.`;
}
function entryHistory(db: DatabaseSync, orgId: string, id: string): string {
const revisions = new KnowledgeService(db).revisions(orgId, id);
if (revisions.length === 0) return `No knowledge entry with id "${id}".`;
const entry = db.prepare("select superseded_by from knowledge_entries where id = ? and org_id = ?")
.get(id, orgId) as { superseded_by: string | null } | undefined;
const state = !entry ? "deleted" : entry.superseded_by ? `superseded by ${entry.superseded_by}` : "current";
return [
`Knowledge entry ${id} (${state}), ${revisions.length} revision(s):`,
...revisions.map((rev) => `r${rev.revision} ${rev.created_at} by ${rev.changed_by_type}${rev.changed_by_id ? ` ${rev.changed_by_id}` : ""} — ${rev.reason}\n ${rev.topic} [${rev.category}, ${rev.importance}]: ${rev.content}`),
].join("\n");
}
/** Prompt lines for the entries most relevant to a task. */
export function relevantKnowledgeLines(db: DatabaseSync, reader: KnowledgeReader, taskText: string): string[] {
const found = relevantItems(knowledgeRows(db, reader), describeEntry, taskText, PROMPT_ENTRY_LIMIT);
+3 -1
View File
@@ -11,7 +11,7 @@ import { bindSkillSlugs, grantSkillTool, listSkillsTool } from "./agent-skill-to
import { createDocumentTool, writeDeliverableTool } from "./agent-deliverable-tools";
import { companyStatusTool, GOAL_TOOLS, updateGoalTool } from "./agent-goal-tools";
import { ATTACHMENT_TOOLS, readAttachmentTool } from "./agent-attachment-tools";
import { KNOWLEDGE_TOOLS, recordKnowledgeTool, searchKnowledgeTool } from "./agent-knowledge-tools";
import { KNOWLEDGE_TOOLS, recordKnowledgeTool, reviseKnowledgeTool, searchKnowledgeTool } from "./agent-knowledge-tools";
import { sendMessageTool } from "./agent-send-message";
import { withSavepoint } from "../database/connection";
import { optionalString, optionalStringArray, requireString } from "./agent-tool-args";
@@ -265,6 +265,8 @@ export function dispatchAgentTool(
return searchKnowledgeTool(deps, ctx, call.arguments);
case "record_knowledge":
return recordKnowledgeTool(deps, ctx, call.arguments);
case "revise_knowledge":
return reviseKnowledgeTool(deps, ctx, call.arguments);
case "read_attachment":
return readAttachmentTool(deps, ctx, call.arguments);
case "company_status":
+64 -1
View File
@@ -1,5 +1,5 @@
import type { DatabaseSync } from "node:sqlite";
import type { CreateKnowledgeEntryInput, DeleteKnowledgeEntryInput, KnowledgeScopeType } from "../../shared/commands";
import type { CreateKnowledgeEntryInput, DeleteKnowledgeEntryInput, KnowledgeScopeType, UpdateKnowledgeEntryInput } from "../../shared/commands";
import { createId } from "../../shared/ids";
import { utcTimestamp } from "./agent-tool-args";
import { AuditService } from "./audit-service";
@@ -33,6 +33,18 @@ export function applicableKnowledgeArgs(reader: KnowledgeReader): Array<string |
return [reader.orgId, new Date().toISOString(), reader.projectId, reader.goalId, reader.taskId, reader.agentId];
}
export interface KnowledgeRevision {
revision: number;
topic: string;
category: string;
content: string;
importance: string;
changed_by_type: string;
changed_by_id: string | null;
reason: string;
created_at: string;
}
export class KnowledgeService {
private readonly audit: AuditService;
@@ -54,6 +66,11 @@ export class KnowledgeService {
if (input.supersedesId) {
this.db.prepare("update knowledge_entries set superseded_by = ?, updated_at = ? where id = ?").run(id, now, input.supersedesId);
}
this.writeRevision({
orgId: input.orgId, entryId: id, revision: 1, topic: input.topic.trim(), category: input.category, content: input.content,
importance: input.importance, changedByType: input.sourceType, changedById: input.sourceId,
reason: input.supersedesId ? `created, superseding ${input.supersedesId}` : "created", at: now,
});
this.audit.record({
orgId: input.orgId,
entityType: "knowledge_entry",
@@ -66,11 +83,57 @@ export class KnowledgeService {
return id;
}
/** Corrects an entry in place; the prior content stays in its revision history. */
update(input: UpdateKnowledgeEntryInput): number {
const reason = input.reason.trim();
if (!reason) throw new Error("A knowledge correction needs a reason.");
const now = new Date().toISOString();
const result = this.db.prepare(`
update knowledge_entries set topic = ?, category = ?, content = ?, importance = ?, updated_at = ?
where id = ? and org_id = ?
`).run(input.topic.trim(), input.category, input.content, input.importance, now, input.entryId, input.orgId);
if (result.changes === 0) throw new Error(`No knowledge entry with id "${input.entryId}".`);
const last = this.db.prepare("select max(revision) as revision from knowledge_revisions where entry_id = ?")
.get(input.entryId) as { revision: number };
const revision = last.revision + 1;
this.writeRevision({
orgId: input.orgId, entryId: input.entryId, revision, topic: input.topic.trim(), category: input.category, content: input.content,
importance: input.importance, changedByType: input.changedByType, changedById: input.changedById, reason, at: now,
});
this.audit.record({
orgId: input.orgId,
entityType: "knowledge_entry",
entityId: input.entryId,
actorType: input.changedByType === "agent" ? "agent" : "employee",
actorId: input.changedById,
action: "revised",
payload: { revision, reason },
});
return revision;
}
revisions(orgId: string, entryId: string): KnowledgeRevision[] {
return this.db.prepare(`
select revision, topic, category, content, importance, changed_by_type, changed_by_id, reason, created_at
from knowledge_revisions where entry_id = ? and org_id = ? order by revision asc
`).all(entryId, orgId) as unknown as KnowledgeRevision[];
}
delete(input: DeleteKnowledgeEntryInput): void {
const result = this.db.prepare("delete from knowledge_entries where id = ? and org_id = ?").run(input.entryId, input.orgId);
if (result.changes === 0) throw new Error("Knowledge entry was not found.");
}
private writeRevision(input: {
orgId: string; entryId: string; revision: number; topic: string; category: string; content: string;
importance: string; changedByType: string; changedById: string | null; reason: string; at: string;
}): void {
this.db.prepare(`
insert into knowledge_revisions(id, org_id, entry_id, revision, topic, category, content, importance, changed_by_type, changed_by_id, reason, created_at)
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(createId("knr"), input.orgId, input.entryId, input.revision, input.topic, input.category, input.content, input.importance, input.changedByType, input.changedById, input.reason, input.at);
}
private assertScope(orgId: string, scopeType: KnowledgeScopeType, scopeId: string | null): void {
if (scopeType === "organization") {
if (scopeId !== null) throw new Error("Organization-wide knowledge takes no scope id.");