Q26: knowledge has scope, expiry, and supersession

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:47:03 -04:00
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'));
`;
+2
View File
@@ -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;
+40 -12
View File
@@ -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 {
+3 -5
View File
@@ -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 };
+15
View File
@@ -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);
}
+61 -5
View File
@@ -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}.`);
}
}
+6 -2
View File
@@ -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,
});
}
+5 -5
View File
@@ -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",
+9
View File
@@ -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 {
+4
View File
@@ -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;
}
+3
View File
@@ -56,6 +56,9 @@ describe("run memory", () => {
topic: expect.stringMatching(/^Outcome: /), category: "run_outcome",
content: "Chose Postgres for bookings; MySQL lacked the JSON features we need.", source_type: "task_run",
}]);
const project = (h.connection.db.prepare("select t.project_id from tasks t join task_runs r on r.task_id = t.id where r.id = ?").get(kickoff.id) as { project_id: string | null }).project_id;
expect(project).not.toBeNull();
expect(h.connection.db.prepare("select scope_type, scope_id from knowledge_entries").get()).toEqual({ scope_type: "project", scope_id: project });
h.connection.close();
});
+2 -2
View File
@@ -65,8 +65,8 @@ describe("task prompt scope", () => {
await execution.executeRun(kickoff!.org_id, kickoff!.id);
const task = connection.db.prepare("select id from tasks where org_id = ? and title = 'Build the booking API'").get(orgId) as { id: string };
new KnowledgeService(connection.db).create({ orgId, topic: "Booking storage", category: "decision", content: "Bookings live in Postgres.", importance: "high", sourceType: "employee", sourceId: null });
new KnowledgeService(connection.db).create({ orgId, topic: "Offsite venue", category: "fact", content: "Lisbon in May.", importance: "low", sourceType: "employee", sourceId: null });
new KnowledgeService(connection.db).create({ orgId, topic: "Booking storage", category: "decision", content: "Bookings live in Postgres.", importance: "high", sourceType: "employee", sourceId: null, scopeType: "organization", scopeId: null, validUntil: null, supersedesId: null });
new KnowledgeService(connection.db).create({ orgId, topic: "Offsite venue", category: "fact", content: "Lisbon in May.", importance: "low", sourceType: "employee", sourceId: null, scopeType: "organization", scopeId: null, validUntil: null, supersedesId: null });
await execution.runTask({ orgId, taskId: task.id });
const danaId = (connection.db.prepare("select id from agents where org_id = ? and name = 'Dana Lee'").get(orgId) as { id: string }).id;
+92 -24
View File
@@ -7,56 +7,124 @@ import { OrganizationService } from "../../src/main/services/organization-servic
import { ChatService } from "../../src/main/services/chat-service";
import { dispatchAgentTool, type AgentToolContext } from "../../src/main/services/agent-tools";
import { relevantKnowledgeLines } from "../../src/main/services/agent-knowledge-tools";
import { RISK_LEVEL, TASK_PRIORITY, TASK_STATUS } from "../../src/shared/domain";
function harness() {
const connection = new DatabaseConnection(join(tmpdir(), `ac-knowledge-${randomUUID()}.db`));
const organization = new OrganizationService(connection.db);
let orgId = "";
let agentId = "";
let otherAgentId = "";
let projectA = "";
let projectB = "";
let taskA = "";
connection.transaction(() => {
orgId = organization.createOrganization({ organizationName: "K", employeeName: "Y", mission: "m", goal: "g" });
agentId = organization.createAgent({ orgId, name: "Ana", role: "Engineer", department: "Engineering", modelProviderId: null, modelId: null, budgetCents: 1000, managerAgentId: null });
otherAgentId = organization.createAgent({ orgId, name: "Ben", role: "Engineer", department: "Engineering", modelProviderId: null, modelId: null, budgetCents: 1000, managerAgentId: null });
projectA = organization.createProject({ orgId, name: "Bookings", description: "d", goalId: null, leadAgentId: null });
projectB = organization.createProject({ orgId, name: "Billing", description: "d", goalId: null, leadAgentId: null });
taskA = organization.createTask({
orgId, title: "Build bookings", description: "d", status: TASK_STATUS.BACKLOG, assigneeAgentId: agentId,
risk: RISK_LEVEL.LOW, projectId: projectA, goalId: null, workspaceId: null, priority: TASK_PRIORITY.NORMAL, requiresFilesystem: false,
});
});
const ctx: AgentToolContext = {
orgId, agent: { id: agentId, modelProviderId: null, modelId: null },
taskProjectId: null, taskGoalId: null, taskId: null, sourceThreadId: null, workspace: null,
};
const run = (name: string, args: Record<string, unknown>) =>
dispatchAgentTool({ db: connection.db, organization, chat: new ChatService(connection.db) }, ctx, { id: `c_${randomUUID()}`, name, arguments: args });
return { connection, orgId, agentId, run };
const ctx = (agent: string, projectId: string | null, taskId: string | null): AgentToolContext => ({
orgId, agent: { id: agent, modelProviderId: null, modelId: null },
taskProjectId: projectId, taskGoalId: null, taskId, sourceThreadId: null, workspace: null,
});
const run = (context: AgentToolContext, name: string, args: Record<string, unknown>) =>
dispatchAgentTool({ db: connection.db, organization, chat: new ChatService(connection.db) }, context, { id: `c_${randomUUID()}`, name, arguments: args });
const reader = (agent: string, projectId: string | null, taskId: string | null) => ({ orgId, agentId: agent, projectId, goalId: null, taskId });
return { connection, orgId, agentId, otherAgentId, projectA, projectB, taskA, ctx, run, reader };
}
describe("knowledge in the execution chain", () => {
test("agents record knowledge and find it again by search", () => {
const h = harness();
expect(h.run("record_knowledge", { topic: "Booking storage", content: "The booking API stores orders in Postgres, not MySQL.", category: "decision", importance: "high" }))
const ana = h.ctx(h.agentId, null, null);
expect(h.run(ana, "record_knowledge", { topic: "Booking storage", content: "The booking API stores orders in Postgres, not MySQL.", category: "decision", importance: "high" }))
.toMatch(/^Recorded knowledge entry kne_/);
h.run("record_knowledge", { topic: "Brand colors", content: "Use teal and sand.", category: "preference" });
const row = h.connection.db.prepare("select source_type, source_id, importance from knowledge_entries where topic = 'Booking storage'").get();
expect(row).toEqual({ source_type: "agent", source_id: h.agentId, importance: "high" });
h.run(ana, "record_knowledge", { topic: "Brand colors", content: "Use teal and sand.", category: "preference" });
const row = h.connection.db.prepare("select source_type, source_id, importance, scope_type, scope_id from knowledge_entries where topic = 'Booking storage'").get();
expect(row).toEqual({ source_type: "agent", source_id: h.agentId, importance: "high", scope_type: "organization", scope_id: null });
const found = h.run("search_knowledge", { query: "which database for bookings postgres" });
expect(found).toContain("Booking storage [decision, high");
const found = h.run(ana, "search_knowledge", { query: "which database for bookings postgres" });
expect(found).toContain("Booking storage [decision, high, organization, id=");
expect(found).not.toContain("Brand colors");
expect(h.run("search_knowledge", { query: "kubernetes" })).toBe('No knowledge matches "kubernetes".');
expect(h.run("record_knowledge", { topic: "x", content: "y", category: "z", importance: "urgent" })).toBe('ERROR: "importance" must be one of: low, normal, high.');
expect(h.run("record_knowledge", { topic: "x", content: "y", category: "z", importance: "" })).toBe('ERROR: "importance" must be one of: low, normal, high.');
expect(h.run("search_knowledge", { query: "postgres", limit: "nonsense" })).toBe('ERROR: "limit" must be a whole number from 1 to 30.');
expect(h.run("search_knowledge", { query: "postgres", limit: 0 })).toBe('ERROR: "limit" must be a whole number from 1 to 30.');
expect(h.run("read_messages", { channel: "general", limit: 99 })).toBe('ERROR: "limit" must be a whole number from 1 to 30.');
expect(h.run(ana, "search_knowledge", { query: "kubernetes" })).toBe('No knowledge matches "kubernetes".');
expect(h.run(ana, "record_knowledge", { topic: "x", content: "y", category: "z", importance: "urgent" })).toBe('ERROR: "importance" must be one of: low, normal, high.');
expect(h.run(ana, "record_knowledge", { topic: "x", content: "y", category: "z", importance: "" })).toBe('ERROR: "importance" must be one of: low, normal, high.');
expect(h.run(ana, "search_knowledge", { query: "postgres", limit: "nonsense" })).toBe('ERROR: "limit" must be a whole number from 1 to 30.');
expect(h.run(ana, "search_knowledge", { query: "postgres", limit: 0 })).toBe('ERROR: "limit" must be a whole number from 1 to 30.');
expect(h.run(ana, "read_messages", { channel: "general", limit: 99 })).toBe('ERROR: "limit" must be a whole number from 1 to 30.');
expect(h.connection.db.prepare("select count(*) as n from knowledge_entries where topic = 'x'").get()).toEqual({ n: 0 });
h.connection.close();
});
test("only knowledge relevant to the task enters its prompt", () => {
const h = harness();
h.run("record_knowledge", { topic: "Booking storage", content: "The booking API stores orders in Postgres.", category: "decision" });
h.run("record_knowledge", { topic: "Brand colors", content: "Use teal and sand.", category: "preference" });
const lines = relevantKnowledgeLines(h.connection.db, h.orgId, "Build the booking API").join("\n");
const ana = h.ctx(h.agentId, null, null);
h.run(ana, "record_knowledge", { topic: "Booking storage", content: "The booking API stores orders in Postgres.", category: "decision" });
h.run(ana, "record_knowledge", { topic: "Brand colors", content: "Use teal and sand.", category: "preference" });
const lines = relevantKnowledgeLines(h.connection.db, h.reader(h.agentId, null, null), "Build the booking API").join("\n");
expect(lines).toContain("Company knowledge that may apply (search_knowledge for more):");
expect(lines).toContain("- Booking storage [decision, normal");
expect(lines).toContain("- Booking storage [decision, normal, organization");
expect(lines).not.toContain("Brand colors");
expect(relevantKnowledgeLines(h.connection.db, h.orgId, "Plan the offsite")).toEqual([]);
expect(relevantKnowledgeLines(h.connection.db, h.reader(h.agentId, null, null), "Plan the offsite")).toEqual([]);
h.connection.close();
});
});
describe("knowledge scope and validity", () => {
test("project, task, and agent scopes only reach readers inside them", () => {
const h = harness();
const inTask = h.ctx(h.agentId, h.projectA, h.taskA);
h.run(inTask, "record_knowledge", { topic: "Retry rule", content: "Booking payment retries twice.", category: "decision", scope: "project" });
h.run(inTask, "record_knowledge", { topic: "Draft state", content: "Booking draft lives in drafts table.", category: "fact", scope: "task" });
h.run(inTask, "record_knowledge", { topic: "My habit", content: "I prefer small booking commits.", category: "preference", scope: "agent" });
const scopes = h.connection.db.prepare("select topic, scope_type, scope_id from knowledge_entries order by topic").all();
expect(scopes).toEqual([
{ topic: "Draft state", scope_type: "task", scope_id: h.taskA },
{ topic: "My habit", scope_type: "agent", scope_id: h.agentId },
{ topic: "Retry rule", scope_type: "project", scope_id: h.projectA },
]);
const seen = (reader: ReturnType<typeof h.reader>) => relevantKnowledgeLines(h.connection.db, reader, "booking").join("\n");
const insideAll = seen(h.reader(h.agentId, h.projectA, h.taskA));
expect(["Retry rule", "Draft state", "My habit"].every((topic) => insideAll.includes(topic))).toBe(true);
const otherProject = seen(h.reader(h.otherAgentId, h.projectB, null));
expect(otherProject).toBe("");
const sameProjectOtherAgent = seen(h.reader(h.otherAgentId, h.projectA, null));
expect(sameProjectOtherAgent).toContain("Retry rule");
expect(sameProjectOtherAgent).not.toContain("Draft state");
expect(sameProjectOtherAgent).not.toContain("My habit");
expect(h.run(h.ctx(h.agentId, null, null), "record_knowledge", { topic: "x", content: "y", category: "z", scope: "project" }))
.toBe("ERROR: This run has no project to scope knowledge to.");
h.connection.close();
});
test("expired and superseded entries stop applying; replacements are tracked", () => {
const h = harness();
const ana = h.ctx(h.agentId, null, null);
h.run(ana, "record_knowledge", { topic: "Promo code", content: "Booking promo SPRING applies.", category: "fact", valid_until: "2020-01-01T00:00:00Z" });
const oldId = /kne_\w+/.exec(h.run(ana, "record_knowledge", { topic: "Booking price", content: "Booking costs 10 dollars.", category: "fact" }))![0];
const newId = /kne_\w+/.exec(h.run(ana, "record_knowledge", { topic: "Booking price", content: "Booking costs 12 dollars.", category: "fact", supersedes: oldId }))![0];
expect(h.connection.db.prepare("select superseded_by from knowledge_entries where id = ?").get(oldId)).toEqual({ superseded_by: newId });
const found = h.run(ana, "search_knowledge", { query: "booking" });
expect(found).toContain("12 dollars");
expect(found).not.toContain("10 dollars");
expect(found).not.toContain("SPRING");
expect(h.run(ana, "record_knowledge", { topic: "p", content: "q", category: "fact", supersedes: oldId }))
.toBe(`ERROR: Knowledge entry ${oldId} was already superseded by ${newId}.`);
expect(h.run(ana, "record_knowledge", { topic: "p", content: "q", category: "fact", valid_until: "next week" }))
.toBe('ERROR: "valid_until" must be a UTC ISO timestamp like 2026-12-31T00:00:00Z, got "next week".');
expect(h.run(ana, "record_knowledge", { topic: "p", content: "q", category: "fact", valid_until: "" })).toBe('ERROR: "valid_until" is required.');
expect(h.run(ana, "record_knowledge", { topic: "p", content: "q", category: "fact", supersedes: 123 })).toBe('ERROR: "supersedes" is required.');
expect(h.connection.db.prepare("select count(*) as n from knowledge_entries where topic = 'p'").get()).toEqual({ n: 0 });
h.connection.db.prepare("delete from knowledge_entries where id = ?").run(newId);
expect(h.connection.db.prepare("select superseded_by from knowledge_entries where id = ?").get(oldId)).toEqual({ superseded_by: newId });
expect(h.run(ana, "search_knowledge", { query: "booking" })).not.toContain("10 dollars");
h.connection.close();
});
});
+1 -1
View File
@@ -24,7 +24,7 @@ describe("domain services", () => {
const connection = createConnection();
const rows = connection.db.prepare("select version from schema_migrations order by version asc").all() as Array<{ version: number }>;
expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62]);
expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63]);
connection.close();
});