Q26: knowledge has scope, expiry, and supersession
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8c70111c93
commit
18128b5ad4
14 files changed
+252
-56
No files matched your search
@@ -0,0 +1,9 @@
|
||||
export const knowledgeScopeSchema = `
|
||||
alter table knowledge_entries add column scope_type text not null default 'organization';
|
||||
alter table knowledge_entries add column scope_id text;
|
||||
alter table knowledge_entries add column valid_until text;
|
||||
alter table knowledge_entries add column superseded_by text;
|
||||
|
||||
insert into schema_migrations(version, name, applied_at)
|
||||
values (63, 'knowledge_scope', datetime('now'));
|
||||
`;
|
||||
@@ -61,6 +61,7 @@ import { taskHandoffSchema } from "./migrations/059-task-handoff";
|
||||
import { chatWakeTextPurgeSchema } from "./migrations/060-chat-wake-text-purge";
|
||||
import { taskRunContextSchema } from "./migrations/061-task-run-context";
|
||||
import { providerContextWindowSchema } from "./migrations/062-provider-context-window";
|
||||
import { knowledgeScopeSchema } from "./migrations/063-knowledge-scope";
|
||||
|
||||
interface Migration {
|
||||
version: number;
|
||||
@@ -131,6 +132,7 @@ const MIGRATIONS: Migration[] = [
|
||||
{ version: 60, name: "chat_wake_text_purge", sql: chatWakeTextPurgeSchema },
|
||||
{ version: 61, name: "task_run_context", sql: taskRunContextSchema },
|
||||
{ version: 62, name: "provider_context_window", sql: providerContextWindowSchema },
|
||||
{ version: 63, name: "knowledge_scope", sql: knowledgeScopeSchema },
|
||||
];
|
||||
|
||||
const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import { optionalEnum, optionalLimit, requireString } from "./agent-tool-args";
|
||||
import { optionalEnum, optionalLimit, optionalText, requireString } from "./agent-tool-args";
|
||||
import type { AgentToolContext, AgentToolDeps } from "./agent-tools";
|
||||
import { KnowledgeService } from "./knowledge-service";
|
||||
import { APPLICABLE_KNOWLEDGE_SQL, applicableKnowledgeArgs, KnowledgeService, type KnowledgeReader } from "./knowledge-service";
|
||||
import type { JsonRecord } from "./provider-http";
|
||||
import type { ToolDefinition } from "./provider-tool-turn";
|
||||
import { relevantItems } from "./relevance";
|
||||
@@ -9,6 +9,7 @@ import { relevantItems } from "./relevance";
|
||||
const PROMPT_ENTRY_LIMIT = 5;
|
||||
const PROMPT_ENTRY_CHARS = 600;
|
||||
const IMPORTANCE = ["low", "normal", "high"] as const;
|
||||
const SCOPES = ["organization", "project", "goal", "task", "agent"] as const;
|
||||
|
||||
interface KnowledgeRow {
|
||||
id: string;
|
||||
@@ -16,6 +17,8 @@ interface KnowledgeRow {
|
||||
category: string;
|
||||
content: string;
|
||||
importance: string;
|
||||
scope_type: string;
|
||||
valid_until: string | null;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
@@ -35,7 +38,7 @@ export const KNOWLEDGE_TOOLS: ToolDefinition[] = [
|
||||
},
|
||||
{
|
||||
name: "record_knowledge",
|
||||
description: "Record something the company should remember: a decision, a preference, a verified fact, or why something failed.",
|
||||
description: "Record something the company should remember: a decision, a preference, a verified fact, or why something failed. Scope it to where it applies; pass supersedes to replace an outdated entry.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
@@ -44,6 +47,9 @@ export const KNOWLEDGE_TOOLS: ToolDefinition[] = [
|
||||
content: { type: "string", description: "The knowledge itself, self-contained." },
|
||||
category: { type: "string", description: "e.g. decision, preference, fact, lesson." },
|
||||
importance: { type: "string", enum: ["low", "normal", "high"] },
|
||||
scope: { type: "string", enum: [...SCOPES], description: "Where it applies: the whole company (default), this task's project, goal, or task, or only you (agent)." },
|
||||
valid_until: { type: "string", description: "UTC ISO time after which it no longer applies." },
|
||||
supersedes: { type: "string", description: "id of an older entry this one replaces." },
|
||||
},
|
||||
required: ["topic", "content", "category"],
|
||||
},
|
||||
@@ -53,13 +59,14 @@ export const KNOWLEDGE_TOOLS: ToolDefinition[] = [
|
||||
export function searchKnowledgeTool(deps: AgentToolDeps, ctx: AgentToolContext, args: JsonRecord): string {
|
||||
const query = requireString(args.query, "query");
|
||||
const limit = optionalLimit(args.limit, "limit", 10, 30);
|
||||
const found = relevantItems(knowledgeRows(deps.db, ctx.orgId), describeEntry, query, limit);
|
||||
const found = relevantItems(knowledgeRows(deps.db, readerOf(ctx)), describeEntry, query, limit);
|
||||
if (found.length === 0) return `No knowledge matches "${query}".`;
|
||||
return found.map(formatEntry).join("\n\n");
|
||||
}
|
||||
|
||||
export function recordKnowledgeTool(deps: AgentToolDeps, ctx: AgentToolContext, args: JsonRecord): string {
|
||||
const importance = optionalEnum(args.importance, "importance", IMPORTANCE, "normal");
|
||||
const scope = optionalEnum(args.scope, "scope", SCOPES, "organization") as (typeof SCOPES)[number];
|
||||
const id = new KnowledgeService(deps.db).create({
|
||||
orgId: ctx.orgId,
|
||||
topic: requireString(args.topic, "topic"),
|
||||
@@ -68,26 +75,47 @@ export function recordKnowledgeTool(deps: AgentToolDeps, ctx: AgentToolContext,
|
||||
importance,
|
||||
sourceType: "agent",
|
||||
sourceId: ctx.agent.id,
|
||||
scopeType: scope,
|
||||
scopeId: scopeIdFor(ctx, scope),
|
||||
validUntil: optionalText(args.valid_until, "valid_until"),
|
||||
supersedesId: optionalText(args.supersedes, "supersedes"),
|
||||
});
|
||||
return `Recorded knowledge entry ${id}.`;
|
||||
}
|
||||
|
||||
/** Prompt lines for the entries most relevant to a task. */
|
||||
export function relevantKnowledgeLines(db: DatabaseSync, orgId: string, taskText: string): string[] {
|
||||
const found = relevantItems(knowledgeRows(db, orgId), describeEntry, taskText, PROMPT_ENTRY_LIMIT);
|
||||
export function relevantKnowledgeLines(db: DatabaseSync, reader: KnowledgeReader, taskText: string): string[] {
|
||||
const found = relevantItems(knowledgeRows(db, reader), describeEntry, taskText, PROMPT_ENTRY_LIMIT);
|
||||
if (found.length === 0) return [];
|
||||
return [
|
||||
"",
|
||||
"Company knowledge that may apply (search_knowledge for more):",
|
||||
...found.map((entry) => `- ${entry.topic} [${entry.category}, ${entry.importance}, id=${entry.id}]: ${clip(entry.content)}`),
|
||||
...found.map((entry) => `- ${entry.topic} [${labels(entry)}]: ${clip(entry.content)}`),
|
||||
];
|
||||
}
|
||||
|
||||
function knowledgeRows(db: DatabaseSync, orgId: string): KnowledgeRow[] {
|
||||
function knowledgeRows(db: DatabaseSync, reader: KnowledgeReader): KnowledgeRow[] {
|
||||
return db.prepare(`
|
||||
select id, topic, category, content, importance, updated_at from knowledge_entries
|
||||
where org_id = ? order by updated_at desc
|
||||
`).all(orgId) as unknown as KnowledgeRow[];
|
||||
select id, topic, category, content, importance, scope_type, valid_until, updated_at from knowledge_entries
|
||||
where ${APPLICABLE_KNOWLEDGE_SQL} order by updated_at desc
|
||||
`).all(...applicableKnowledgeArgs(reader)) as unknown as KnowledgeRow[];
|
||||
}
|
||||
|
||||
function readerOf(ctx: AgentToolContext): KnowledgeReader {
|
||||
return { orgId: ctx.orgId, agentId: ctx.agent.id, projectId: ctx.taskProjectId, goalId: ctx.taskGoalId, taskId: ctx.taskId };
|
||||
}
|
||||
|
||||
function scopeIdFor(ctx: AgentToolContext, scope: (typeof SCOPES)[number]): string | null {
|
||||
if (scope === "organization") return null;
|
||||
if (scope === "agent") return ctx.agent.id;
|
||||
const id = scope === "project" ? ctx.taskProjectId : scope === "goal" ? ctx.taskGoalId : ctx.taskId;
|
||||
if (!id) throw new Error(`This run has no ${scope} to scope knowledge to.`);
|
||||
return id;
|
||||
}
|
||||
|
||||
function labels(entry: KnowledgeRow): string {
|
||||
const validity = entry.valid_until ? `, valid until ${entry.valid_until}` : "";
|
||||
return `${entry.category}, ${entry.importance}, ${entry.scope_type}${validity}, id=${entry.id}`;
|
||||
}
|
||||
|
||||
function describeEntry(entry: KnowledgeRow): string {
|
||||
@@ -95,7 +123,7 @@ function describeEntry(entry: KnowledgeRow): string {
|
||||
}
|
||||
|
||||
function formatEntry(entry: KnowledgeRow): string {
|
||||
return `${entry.topic} [${entry.category}, ${entry.importance}, id=${entry.id}, updated ${entry.updated_at}]\n${entry.content}`;
|
||||
return `${entry.topic} [${labels(entry)}, updated ${entry.updated_at}]\n${entry.content}`;
|
||||
}
|
||||
|
||||
function clip(text: string): string {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { attachmentNote } from "./agent-attachment-tools";
|
||||
import { optionalLimit, optionalString } from "./agent-tool-args";
|
||||
import { optionalLimit, optionalString, utcTimestamp } from "./agent-tool-args";
|
||||
import type { AgentToolContext, AgentToolDeps } from "./agent-tools";
|
||||
import type { JsonRecord } from "./provider-http";
|
||||
|
||||
@@ -124,10 +124,8 @@ function beforeCursor(deps: AgentToolDeps, orgId: string, text: string | null):
|
||||
if (!row) throw new Error(`No message with id "${text}".`);
|
||||
return { createdAt: row.created_at, rowid: row.row_id };
|
||||
}
|
||||
const match = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(\.\d{3})?Z$/.exec(text);
|
||||
const normalized = match ? `${match[1]}${match[2] ?? ".000"}Z` : "";
|
||||
const time = new Date(normalized);
|
||||
if (!match || Number.isNaN(time.getTime()) || time.toISOString() !== normalized) {
|
||||
const normalized = utcTimestamp(text);
|
||||
if (!normalized) {
|
||||
throw new Error(`"before" must be a message_id from a previous page or a UTC ISO timestamp like 2026-01-31T09:00:00Z, got "${text}".`);
|
||||
}
|
||||
return { createdAt: normalized, rowid: -1 };
|
||||
|
||||
@@ -30,3 +30,18 @@ export function optionalEnum(value: unknown, field: string, allowed: readonly st
|
||||
if (typeof value !== "string" || !allowed.includes(value)) throw new Error(`"${field}" must be one of: ${allowed.join(", ")}.`);
|
||||
return value;
|
||||
}
|
||||
|
||||
/** The text as a normalized UTC ISO timestamp, or undefined when it is not one. */
|
||||
export function utcTimestamp(text: string): string | undefined {
|
||||
const match = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(\.\d{3})?Z$/.exec(text);
|
||||
if (!match) return undefined;
|
||||
const normalized = `${match[1]}${match[2] ?? ".000"}Z`;
|
||||
const time = new Date(normalized);
|
||||
return !Number.isNaN(time.getTime()) && time.toISOString() === normalized ? normalized : undefined;
|
||||
}
|
||||
|
||||
/** Omitted means null; anything supplied must be a non-blank string. */
|
||||
export function optionalText(value: unknown, field: string): string | null {
|
||||
if (value === undefined) return null;
|
||||
return requireString(value, field);
|
||||
}
|
||||
@@ -1,8 +1,38 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type { CreateKnowledgeEntryInput, DeleteKnowledgeEntryInput } from "../../shared/commands";
|
||||
import type { CreateKnowledgeEntryInput, DeleteKnowledgeEntryInput, KnowledgeScopeType } from "../../shared/commands";
|
||||
import { createId } from "../../shared/ids";
|
||||
import { utcTimestamp } from "./agent-tool-args";
|
||||
import { AuditService } from "./audit-service";
|
||||
|
||||
const SCOPE_TABLES: Record<Exclude<KnowledgeScopeType, "organization">, string> = {
|
||||
project: "projects",
|
||||
goal: "goals",
|
||||
task: "tasks",
|
||||
agent: "agents",
|
||||
};
|
||||
|
||||
/** Where a reader stands: the scopes whose knowledge applies to them. */
|
||||
export interface KnowledgeReader {
|
||||
orgId: string;
|
||||
agentId: string | null;
|
||||
projectId: string | null;
|
||||
goalId: string | null;
|
||||
taskId: string | null;
|
||||
}
|
||||
|
||||
/** Active, unexpired entries whose scope covers the reader. */
|
||||
export const APPLICABLE_KNOWLEDGE_SQL = `
|
||||
org_id = ? and superseded_by is null and (valid_until is null or valid_until > ?)
|
||||
and (scope_type = 'organization'
|
||||
or (scope_type = 'project' and scope_id = ?)
|
||||
or (scope_type = 'goal' and scope_id = ?)
|
||||
or (scope_type = 'task' and scope_id = ?)
|
||||
or (scope_type = 'agent' and scope_id = ?))`;
|
||||
|
||||
export function applicableKnowledgeArgs(reader: KnowledgeReader): Array<string | null> {
|
||||
return [reader.orgId, new Date().toISOString(), reader.projectId, reader.goalId, reader.taskId, reader.agentId];
|
||||
}
|
||||
|
||||
export class KnowledgeService {
|
||||
private readonly audit: AuditService;
|
||||
|
||||
@@ -11,12 +41,19 @@ export class KnowledgeService {
|
||||
}
|
||||
|
||||
create(input: CreateKnowledgeEntryInput): string {
|
||||
this.assertScope(input.orgId, input.scopeType, input.scopeId);
|
||||
const validUntil = input.validUntil === null ? null : utcTimestamp(input.validUntil);
|
||||
if (validUntil === undefined) throw new Error(`"valid_until" must be a UTC ISO timestamp like 2026-12-31T00:00:00Z, got "${input.validUntil}".`);
|
||||
const id = createId("kne");
|
||||
const now = new Date().toISOString();
|
||||
if (input.supersedesId) this.assertSupersedable(input.orgId, input.supersedesId);
|
||||
this.db.prepare(`
|
||||
insert into knowledge_entries(id, org_id, topic, category, content, importance, source_type, source_id, created_at, updated_at)
|
||||
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(id, input.orgId, input.topic.trim(), input.category, input.content, input.importance, input.sourceType, input.sourceId, now, now);
|
||||
insert into knowledge_entries(id, org_id, topic, category, content, importance, source_type, source_id, scope_type, scope_id, valid_until, created_at, updated_at)
|
||||
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(id, input.orgId, input.topic.trim(), input.category, input.content, input.importance, input.sourceType, input.sourceId, input.scopeType, input.scopeId, validUntil, now, now);
|
||||
if (input.supersedesId) {
|
||||
this.db.prepare("update knowledge_entries set superseded_by = ?, updated_at = ? where id = ?").run(id, now, input.supersedesId);
|
||||
}
|
||||
this.audit.record({
|
||||
orgId: input.orgId,
|
||||
entityType: "knowledge_entry",
|
||||
@@ -24,7 +61,7 @@ export class KnowledgeService {
|
||||
actorType: "system",
|
||||
actorId: null,
|
||||
action: "created",
|
||||
payload: { topic: input.topic.trim(), category: input.category },
|
||||
payload: { topic: input.topic.trim(), category: input.category, scopeType: input.scopeType, scopeId: input.scopeId, validUntil, supersedes: input.supersedesId },
|
||||
});
|
||||
return id;
|
||||
}
|
||||
@@ -33,4 +70,23 @@ export class KnowledgeService {
|
||||
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 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.");
|
||||
return;
|
||||
}
|
||||
const table = SCOPE_TABLES[scopeType];
|
||||
if (!table) throw new Error(`Unknown knowledge scope "${scopeType}".`);
|
||||
if (!scopeId) throw new Error(`Knowledge scoped to a ${scopeType} needs its id.`);
|
||||
const exists = this.db.prepare(`select 1 from ${table} where id = ? and org_id = ?`).get(scopeId, orgId);
|
||||
if (!exists) throw new Error(`No ${scopeType} with id "${scopeId}" in this company.`);
|
||||
}
|
||||
|
||||
private assertSupersedable(orgId: string, entryId: string): void {
|
||||
const row = this.db.prepare("select superseded_by from knowledge_entries where id = ? and org_id = ?")
|
||||
.get(entryId, orgId) as { superseded_by: string | null } | undefined;
|
||||
if (!row) throw new Error(`No knowledge entry with id "${entryId}" to supersede.`);
|
||||
if (row.superseded_by) throw new Error(`Knowledge entry ${entryId} was already superseded by ${row.superseded_by}.`);
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,10 @@ import { KnowledgeService } from "./knowledge-service";
|
||||
|
||||
const MEMORY_CHARS = 2_000;
|
||||
|
||||
/** Keeps what a finished or failed run of real work concluded, so later runs can find it. */
|
||||
/** Keeps what a run of real work concluded, for its project or the whole company. */
|
||||
export function recordRunMemory(
|
||||
db: DatabaseSync,
|
||||
input: { orgId: string; runId: string; taskTitle: string; outcome: "completed" | "failed"; text: string },
|
||||
input: { orgId: string; runId: string; projectId: string | null; taskTitle: string; outcome: "completed" | "failed"; text: string },
|
||||
): void {
|
||||
const text = input.text.trim();
|
||||
if (!text) return;
|
||||
@@ -18,5 +18,9 @@ export function recordRunMemory(
|
||||
importance: "normal",
|
||||
sourceType: "task_run",
|
||||
sourceId: input.runId,
|
||||
scopeType: input.projectId ? "project" : "organization",
|
||||
scopeId: input.projectId,
|
||||
validUntil: null,
|
||||
supersedesId: null,
|
||||
});
|
||||
}
|
||||
@@ -127,8 +127,8 @@ export class TaskExecutionService {
|
||||
action: "failed",
|
||||
payload: { taskId: run.task_id, agentId: run.agent_id, providerId: run.provider_id, stage: "preparation", errorMessage: message },
|
||||
});
|
||||
const task = this.db.prepare("select title, origin from tasks where id = ?").get(run.task_id) as { title: string; origin: string | null } | undefined;
|
||||
if (task && task.origin === null) recordRunMemory(this.db, { orgId, runId, taskTitle: task.title, outcome: "failed", text: message });
|
||||
const task = this.db.prepare("select title, origin, project_id from tasks where id = ?").get(run.task_id) as { title: string; origin: string | null; project_id: string | null } | undefined;
|
||||
if (task && task.origin === null) recordRunMemory(this.db, { orgId, runId, projectId: task.project_id, taskTitle: task.title, outcome: "failed", text: message });
|
||||
throw error;
|
||||
}
|
||||
return this.executeAgentRun(prepared);
|
||||
@@ -195,7 +195,7 @@ export class TaskExecutionService {
|
||||
managerName: this.agentName(orgId, agent.manager_agent_id),
|
||||
chatContext,
|
||||
handoffLines: renderTaskHandoff(this.db, orgId, task.handoff_json),
|
||||
knowledgeLines: relevantKnowledgeLines(this.db, orgId, `${task.title}\n${task.description}`),
|
||||
knowledgeLines: relevantKnowledgeLines(this.db, { orgId, agentId: agent.id, projectId: task.project_id, goalId: task.goal_id, taskId: task.id }, `${task.title}\n${task.description}`),
|
||||
priorCompletedRuns: this.completedRunCount(task.id, runId),
|
||||
}),
|
||||
toolContext,
|
||||
@@ -223,7 +223,7 @@ export class TaskExecutionService {
|
||||
}
|
||||
this.completeRun(orgId, runId, result.lastRequestId);
|
||||
this.cooldowns.clear(provider);
|
||||
if (task.origin === null) recordRunMemory(this.db, { orgId, runId, taskTitle: task.title, outcome: "completed", text: result.finalText });
|
||||
if (task.origin === null) recordRunMemory(this.db, { orgId, runId, projectId: task.project_id, taskTitle: task.title, outcome: "completed", text: result.finalText });
|
||||
this.audit.record({
|
||||
orgId,
|
||||
entityType: "task_run",
|
||||
@@ -248,7 +248,7 @@ export class TaskExecutionService {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.failRun(orgId, runId, providerRequestId, message);
|
||||
if (isTransientProviderFailure(message)) this.cooldowns.park(provider, message);
|
||||
else if (task.origin === null) recordRunMemory(this.db, { orgId, runId, taskTitle: task.title, outcome: "failed", text: message });
|
||||
else if (task.origin === null) recordRunMemory(this.db, { orgId, runId, projectId: task.project_id, taskTitle: task.title, outcome: "failed", text: message });
|
||||
this.audit.record({
|
||||
orgId,
|
||||
entityType: "task_run",
|
||||
|
||||
@@ -23,6 +23,8 @@ export interface DeleteDocumentInput {
|
||||
documentId: string;
|
||||
}
|
||||
|
||||
export type KnowledgeScopeType = "organization" | "project" | "goal" | "task" | "agent";
|
||||
|
||||
export interface CreateKnowledgeEntryInput {
|
||||
orgId: string;
|
||||
topic: string;
|
||||
@@ -31,6 +33,13 @@ export interface CreateKnowledgeEntryInput {
|
||||
importance: string;
|
||||
sourceType: string;
|
||||
sourceId: string | null;
|
||||
scopeType: KnowledgeScopeType;
|
||||
/** Required unless the scope is organization. */
|
||||
scopeId: string | null;
|
||||
/** UTC ISO time the entry stops applying, or null. */
|
||||
validUntil: string | null;
|
||||
/** An older entry this one replaces. */
|
||||
supersedesId: string | null;
|
||||
}
|
||||
|
||||
export interface DeleteKnowledgeEntryInput {
|
||||
|
||||
@@ -419,6 +419,10 @@ export interface KnowledgeEntryRecord {
|
||||
importance: string;
|
||||
sourceType: string;
|
||||
sourceId: string | null;
|
||||
scopeType: string;
|
||||
scopeId: string | null;
|
||||
validUntil: string | null;
|
||||
supersededBy: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user