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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user