From 18128b5ad43c64addec4d61049e2f1b86005b2ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Thu, 8 Oct 2026 05:47:03 -0400 Subject: [PATCH] Q26: knowledge has scope, expiry, and supersession Co-Authored-By: Claude Opus 5.5 --- .../migrations/063-knowledge-scope.ts | 9 ++ src/main/database/schema.ts | 2 + src/main/services/agent-knowledge-tools.ts | 52 ++++++-- src/main/services/agent-read-messages.ts | 8 +- src/main/services/agent-tool-args.ts | 15 +++ src/main/services/knowledge-service.ts | 66 +++++++++- src/main/services/run-memory.ts | 8 +- src/main/services/task-execution-service.ts | 10 +- src/shared/commands/collaboration.ts | 9 ++ src/shared/domain.ts | 4 + tests/integration/run-memory.test.ts | 3 + tests/integration/task-context-scope.test.ts | 4 +- tests/unit/agent-knowledge-tools.test.ts | 116 ++++++++++++++---- tests/unit/domain-services.test.ts | 2 +- 14 files changed, 252 insertions(+), 56 deletions(-) create mode 100644 src/main/database/migrations/063-knowledge-scope.ts diff --git a/src/main/database/migrations/063-knowledge-scope.ts b/src/main/database/migrations/063-knowledge-scope.ts new file mode 100644 index 00000000..c2aae6b2 --- /dev/null +++ b/src/main/database/migrations/063-knowledge-scope.ts @@ -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')); +`; diff --git a/src/main/database/schema.ts b/src/main/database/schema.ts index aac108fe..0b4211ef 100644 --- a/src/main/database/schema.ts +++ b/src/main/database/schema.ts @@ -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; diff --git a/src/main/services/agent-knowledge-tools.ts b/src/main/services/agent-knowledge-tools.ts index f4c19517..16bb6486 100644 --- a/src/main/services/agent-knowledge-tools.ts +++ b/src/main/services/agent-knowledge-tools.ts @@ -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 { diff --git a/src/main/services/agent-read-messages.ts b/src/main/services/agent-read-messages.ts index 495be89a..c55aea79 100644 --- a/src/main/services/agent-read-messages.ts +++ b/src/main/services/agent-read-messages.ts @@ -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 }; diff --git a/src/main/services/agent-tool-args.ts b/src/main/services/agent-tool-args.ts index 8fa20e0a..37226be4 100644 --- a/src/main/services/agent-tool-args.ts +++ b/src/main/services/agent-tool-args.ts @@ -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); +} diff --git a/src/main/services/knowledge-service.ts b/src/main/services/knowledge-service.ts index e333bbd6..121f77c1 100644 --- a/src/main/services/knowledge-service.ts +++ b/src/main/services/knowledge-service.ts @@ -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, 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 { + 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}.`); + } } diff --git a/src/main/services/run-memory.ts b/src/main/services/run-memory.ts index 7512b099..35f0421c 100644 --- a/src/main/services/run-memory.ts +++ b/src/main/services/run-memory.ts @@ -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, }); } diff --git a/src/main/services/task-execution-service.ts b/src/main/services/task-execution-service.ts index 600013bb..617455ab 100644 --- a/src/main/services/task-execution-service.ts +++ b/src/main/services/task-execution-service.ts @@ -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", diff --git a/src/shared/commands/collaboration.ts b/src/shared/commands/collaboration.ts index 9051960c..672e8b57 100644 --- a/src/shared/commands/collaboration.ts +++ b/src/shared/commands/collaboration.ts @@ -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 { diff --git a/src/shared/domain.ts b/src/shared/domain.ts index 175a49ad..f99fad91 100644 --- a/src/shared/domain.ts +++ b/src/shared/domain.ts @@ -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; } diff --git a/tests/integration/run-memory.test.ts b/tests/integration/run-memory.test.ts index c69fc128..aab6c4aa 100644 --- a/tests/integration/run-memory.test.ts +++ b/tests/integration/run-memory.test.ts @@ -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(); }); diff --git a/tests/integration/task-context-scope.test.ts b/tests/integration/task-context-scope.test.ts index 3afd1cfa..f1b39f4c 100644 --- a/tests/integration/task-context-scope.test.ts +++ b/tests/integration/task-context-scope.test.ts @@ -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; diff --git a/tests/unit/agent-knowledge-tools.test.ts b/tests/unit/agent-knowledge-tools.test.ts index 52ef1b5b..311e7a85 100644 --- a/tests/unit/agent-knowledge-tools.test.ts +++ b/tests/unit/agent-knowledge-tools.test.ts @@ -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) => - 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) => + 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) => 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(); }); }); diff --git a/tests/unit/domain-services.test.ts b/tests/unit/domain-services.test.ts index 92537290..4a119b62 100644 --- a/tests/unit/domain-services.test.ts +++ b/tests/unit/domain-services.test.ts @@ -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(); });