From 84fed8e2f2957d32fd22d2df2da33040f016fe35 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:56:55 -0400 Subject: [PATCH] Q28: agents list, read, and update company documents Co-Authored-By: Claude Opus 5.5 --- src/main/services/agent-deliverable-tools.ts | 2 +- src/main/services/agent-document-tools.ts | 100 +++++++++++++++++++ src/main/services/agent-tools.ts | 10 +- src/main/services/task-prompts.ts | 2 +- src/main/services/text-paging.ts | 3 +- tests/unit/agent-document-tools.test.ts | 62 ++++++++++++ tests/unit/text-paging.test.ts | 2 + 7 files changed, 177 insertions(+), 4 deletions(-) create mode 100644 src/main/services/agent-document-tools.ts create mode 100644 tests/unit/agent-document-tools.test.ts diff --git a/src/main/services/agent-deliverable-tools.ts b/src/main/services/agent-deliverable-tools.ts index 0300783f..df3dfb90 100644 --- a/src/main/services/agent-deliverable-tools.ts +++ b/src/main/services/agent-deliverable-tools.ts @@ -58,7 +58,7 @@ export function createDocumentTool(deps: AgentToolDeps, ctx: AgentToolContext, a * every deliverable and document lands in the Approvals queue. AI CEO mode * ships without review — that autonomy is the mode's contract. */ -function requestDeliverableReview( +export function requestDeliverableReview( deps: AgentToolDeps, ctx: AgentToolContext, input: { title: string; details: string; sourceEntityType: "file" | "document"; sourceEntityId: string }, diff --git a/src/main/services/agent-document-tools.ts b/src/main/services/agent-document-tools.ts new file mode 100644 index 00000000..d7a742ef --- /dev/null +++ b/src/main/services/agent-document-tools.ts @@ -0,0 +1,100 @@ +import { optionalEnum, optionalLimit, optionalText, requireString } from "./agent-tool-args"; +import { requestDeliverableReview } from "./agent-deliverable-tools"; +import type { AgentToolContext, AgentToolDeps } from "./agent-tools"; +import { DocumentService } from "./document-service"; +import type { JsonRecord } from "./provider-http"; +import type { ToolDefinition } from "./provider-tool-turn"; +import { relevantItems } from "./relevance"; +import { PAGE_TOOL_PROPERTIES, pageText, readPageArgs } from "./text-paging"; + +export const DOCUMENT_TYPES = ["prd", "spec", "memo", "report", "runbook", "proposal", "policy", "guide"] as const; + +interface DocumentRow { + id: string; + title: string; + doc_type: string; + status: string; + content: string; + updated_at: string; +} + +export const DOCUMENT_TOOLS: ToolDefinition[] = [ + { + name: "list_documents", + description: "Find company documents (PRDs, specs, reports, ...) by words in their title or body, or by type. Returns doc_ids for read_document.", + parameters: { + type: "object", + additionalProperties: false, + properties: { + query: { type: "string", description: "Words to look for; omit to list the newest documents." }, + doc_type: { type: "string", enum: [...DOCUMENT_TYPES] }, + limit: { type: "number", description: "Most documents to return (default 10, max 30)." }, + }, + required: [], + }, + }, + { + name: "read_document", + description: "Read a company document by doc_id — whole, a range of lines, or only the lines matching a search.", + parameters: { + type: "object", + additionalProperties: false, + properties: { doc_id: { type: "string" }, ...PAGE_TOOL_PROPERTIES }, + required: ["doc_id"], + }, + }, + { + name: "update_document", + description: "Replace a company document's content (and optionally its title). Say why in reason.", + parameters: { + type: "object", + additionalProperties: false, + properties: { + doc_id: { type: "string" }, + content: { type: "string", description: "The full new content in markdown." }, + title: { type: "string" }, + reason: { type: "string", description: "What changed and why." }, + }, + required: ["doc_id", "content", "reason"], + }, + }, +]; + +export function listDocumentsTool(deps: AgentToolDeps, ctx: AgentToolContext, args: JsonRecord): string { + const query = optionalText(args.query, "query"); + const docType = args.doc_type === undefined ? null : optionalEnum(args.doc_type, "doc_type", DOCUMENT_TYPES, ""); + const limit = optionalLimit(args.limit, "limit", 10, 30); + const rows = deps.db.prepare(` + select id, title, doc_type, status, content, updated_at from documents + where org_id = ? and (? is null or doc_type = ?) order by updated_at desc + `).all(ctx.orgId, docType, docType) as unknown as DocumentRow[]; + const found = query ? relevantItems(rows, (row) => `${row.title} ${row.content}`, query, limit) : rows.slice(0, limit); + if (found.length === 0) return query ? `No documents match "${query}".` : "No documents yet."; + return found.map((row) => `${row.title} (doc_id=${row.id}, ${row.doc_type}, ${row.status}, ${row.content.length} chars, updated ${row.updated_at})`).join("\n"); +} + +export function readDocumentTool(deps: AgentToolDeps, ctx: AgentToolContext, args: JsonRecord): string { + const id = requireString(args.doc_id, "doc_id"); + const doc = deps.db.prepare("select title, doc_type, status, content from documents where id = ? and org_id = ?") + .get(id, ctx.orgId) as { title: string; doc_type: string; status: string; content: string } | undefined; + if (!doc) throw new Error(`No document with doc_id "${id}".`); + return `${doc.title} (${doc.doc_type}, ${doc.status})\n\n${pageText(doc.title, doc.content, readPageArgs(args))}`; +} + +export function updateDocumentTool(deps: AgentToolDeps, ctx: AgentToolContext, args: JsonRecord): string { + const id = requireString(args.doc_id, "doc_id"); + const doc = deps.db.prepare("select title, doc_type, status from documents where id = ? and org_id = ?") + .get(id, ctx.orgId) as { title: string; doc_type: string; status: string } | undefined; + if (!doc) throw new Error(`No document with doc_id "${id}".`); + const content = requireString(args.content, "content"); + const title = optionalText(args.title, "title") ?? doc.title; + const reason = requireString(args.reason, "reason"); + new DocumentService(deps.db).update({ orgId: ctx.orgId, documentId: id, title, content, status: doc.status }); + const review = requestDeliverableReview(deps, ctx, { + title: `Review ${doc.doc_type} update: ${title}`, + details: `Document "${title}" was updated by an agent run (${content.length} chars). Reason: ${reason}`, + sourceEntityType: "document", + sourceEntityId: id, + }); + return `Updated document "${title}" (doc_id=${id}).${review ? " The update is queued for board review in Approvals." : ""}`; +} diff --git a/src/main/services/agent-tools.ts b/src/main/services/agent-tools.ts index 0c4f3b79..9835f0e7 100644 --- a/src/main/services/agent-tools.ts +++ b/src/main/services/agent-tools.ts @@ -12,6 +12,7 @@ import { createDocumentTool, writeDeliverableTool } from "./agent-deliverable-to import { companyStatusTool, GOAL_TOOLS, updateGoalTool } from "./agent-goal-tools"; import { ATTACHMENT_TOOLS, readAttachmentTool } from "./agent-attachment-tools"; import { KNOWLEDGE_TOOLS, recordKnowledgeTool, reviseKnowledgeTool, searchKnowledgeTool } from "./agent-knowledge-tools"; +import { DOCUMENT_TOOLS, DOCUMENT_TYPES, listDocumentsTool, readDocumentTool, updateDocumentTool } from "./agent-document-tools"; import { sendMessageTool } from "./agent-send-message"; import { withSavepoint } from "../database/connection"; import { optionalString, optionalStringArray, requireString } from "./agent-tool-args"; @@ -160,7 +161,7 @@ export const AGENT_TOOLS: ToolDefinition[] = [ additionalProperties: false, properties: { title: { type: "string" }, - doc_type: { type: "string", enum: ["prd", "spec", "memo", "report", "runbook", "proposal", "policy", "guide"] }, + doc_type: { type: "string", enum: [...DOCUMENT_TYPES] }, content: { type: "string", description: "Full document content in markdown." }, }, required: ["title", "doc_type", "content"], @@ -231,6 +232,7 @@ export const AGENT_TOOLS: ToolDefinition[] = [ ...GOAL_TOOLS, ...ATTACHMENT_TOOLS, ...KNOWLEDGE_TOOLS, + ...DOCUMENT_TOOLS, ]; export function dispatchAgentTool( @@ -255,6 +257,12 @@ export function dispatchAgentTool( return readMessagesTool(deps, ctx, call.arguments); case "create_document": return createDocumentTool(deps, ctx, call.arguments); + case "list_documents": + return listDocumentsTool(deps, ctx, call.arguments); + case "read_document": + return readDocumentTool(deps, ctx, call.arguments); + case "update_document": + return updateDocumentTool(deps, ctx, call.arguments); case "list_connectors": return listConnectorsTool(deps, ctx); case "grant_connector": diff --git a/src/main/services/task-prompts.ts b/src/main/services/task-prompts.ts index db7b1aeb..3f35f241 100644 --- a/src/main/services/task-prompts.ts +++ b/src/main/services/task-prompts.ts @@ -106,7 +106,7 @@ export function buildAgentInstructions(input: AgentInstructionContext): string { `You are ${agent.name}, ${agent.title || agent.role} in ${agent.department} at ${input.companyName}. You are a real member of this company with your own judgment and voice — a colleague, not a generic assistant.`, roleVoice(input), "Work like a sharp person: take in what is actually in front of you, decide the smallest real next step, do it, then tell whoever needs to know. Do not narrate busywork.", - `Your tools are how you act: hire_agent builds your team, create_task delegates and assigns work by name (set requires_filesystem for engineering work that must change code or run commands), update_task_status tracks it, list_files and read_file explore the company workspace, write_deliverable produces real files, run_command builds and tests for real on filesystem tasks, create_document writes versioned company documents (PRDs, specs, memos, reports), search_knowledge and record_knowledge read and grow what the company has learned, read_messages catches you up on what teammates said, and send_message is how you talk to everyone. web_search and fetch_url reach the live web — look up APIs, docs, and facts instead of guessing them.`, + `Your tools are how you act: hire_agent builds your team, create_task delegates and assigns work by name (set requires_filesystem for engineering work that must change code or run commands), update_task_status tracks it, list_files and read_file explore the company workspace, write_deliverable produces real files, run_command builds and tests for real on filesystem tasks, create_document writes company documents (PRDs, specs, memos, reports) and list_documents, read_document, update_document find and revise them, search_knowledge and record_knowledge read and grow what the company has learned, read_messages catches you up on what teammates said, and send_message is how you talk to everyone. web_search and fetch_url reach the live web — look up APIs, docs, and facts instead of guessing them.`, ...(input.language === "zh" ? ["公司的工作语言是简体中文:你写的每一条消息、任务标题与描述、文档和交付物都用中文表达(代码、命令、API 名称等技术标识保持原样)。工具名称和工具参数的字段名仍按定义使用英文。"] : []), diff --git a/src/main/services/text-paging.ts b/src/main/services/text-paging.ts index 72f9fe8c..713e3920 100644 --- a/src/main/services/text-paging.ts +++ b/src/main/services/text-paging.ts @@ -1,3 +1,4 @@ +import { optionalText } from "./agent-tool-args"; import type { JsonRecord } from "./provider-http"; const PAGE_CHAR_LIMIT = 24_000; @@ -18,7 +19,7 @@ export const PAGE_TOOL_PROPERTIES = { } as const; export function readPageArgs(args: JsonRecord): PageRequest { - const search = typeof args.search === "string" && args.search.trim() ? args.search.trim() : null; + const search = optionalText(args.search, "search"); return { startLine: positiveInteger(args.start_line, "start_line") ?? 1, startColumn: positiveInteger(args.start_column, "start_column") ?? 1, diff --git a/tests/unit/agent-document-tools.test.ts b/tests/unit/agent-document-tools.test.ts new file mode 100644 index 00000000..70852b26 --- /dev/null +++ b/tests/unit/agent-document-tools.test.ts @@ -0,0 +1,62 @@ +import { randomUUID } from "node:crypto"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, test } from "vitest"; +import { DatabaseConnection } from "../../src/main/database/connection"; +import { OrganizationService } from "../../src/main/services/organization-service"; +import { ChatService } from "../../src/main/services/chat-service"; +import { dispatchAgentTool, type AgentToolContext } from "../../src/main/services/agent-tools"; + +function harness(reviewDeliverables: boolean) { + const connection = new DatabaseConnection(join(tmpdir(), `ac-docs-${randomUUID()}.db`)); + const organization = new OrganizationService(connection.db); + let orgId = ""; + let agentId = ""; + connection.transaction(() => { + orgId = organization.createOrganization({ organizationName: "D", employeeName: "Y", mission: "m", goal: "g" }); + agentId = organization.createAgent({ orgId, name: "Ana", role: "PM", department: "Product", modelProviderId: null, modelId: null, budgetCents: 1000, managerAgentId: null }); + }); + connection.db.prepare("update organizations set review_deliverables = ? where id = ?").run(reviewDeliverables ? 1 : 0, orgId); + 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, run }; +} + +describe("agent document tools", () => { + test("documents an agent wrote can be found, read in pages, and updated", () => { + const h = harness(false); + const created = h.run("create_document", { title: "Booking PRD", doc_type: "prd", content: Array.from({ length: 3000 }, (_, i) => `Requirement ${i + 1}: booking flow`).join("\n") }); + const docId = /doc_id=(doc_\w+)/.exec(created)![1]; + h.run("create_document", { title: "Q3 Report", doc_type: "report", content: "Revenue grew." }); + + expect(h.run("list_documents", { query: "booking" })).toMatch(new RegExp(`^Booking PRD \\(doc_id=${docId}, prd, draft, \\d+ chars`)); + expect(h.run("list_documents", { doc_type: "report" })).toMatch(/^Q3 Report \(doc_id=doc_\w+, report/); + expect(h.run("list_documents", { query: "kubernetes" })).toBe('No documents match "kubernetes".'); + + const page = h.run("read_document", { doc_id: docId }); + expect(page.startsWith("Booking PRD (prd, draft)\n\n[lines 1-")).toBe(true); + expect(page).toContain("[more: pass start_line="); + expect(h.run("read_document", { doc_id: docId, search: "Requirement 2999:" })).toContain("2999: Requirement 2999: booking flow"); + + expect(h.run("update_document", { doc_id: docId, content: "Requirement 1: rebooking", reason: "Scope cut" })).toBe(`Updated document "Booking PRD" (doc_id=${docId}).`); + expect(h.run("read_document", { doc_id: docId })).toBe("Booking PRD (prd, draft)\n\nRequirement 1: rebooking"); + expect(h.run("update_document", { doc_id: docId, content: "x" })).toBe('ERROR: "reason" is required.'); + expect(h.run("read_document", { doc_id: "doc_missing" })).toBe('ERROR: No document with doc_id "doc_missing".'); + expect(h.run("read_document", { doc_id: docId, search: 123 })).toBe('ERROR: "search" is required.'); + expect(h.run("list_documents", { doc_type: "novel" })).toBe('ERROR: "doc_type" must be one of: prd, spec, memo, report, runbook, proposal, policy, guide.'); + h.connection.close(); + }); + + test("in review mode an update goes to Approvals", () => { + const h = harness(true); + const docId = /doc_id=(doc_\w+)/.exec(h.run("create_document", { title: "Policy", doc_type: "policy", content: "v1" }))![1]; + expect(h.run("update_document", { doc_id: docId, content: "v2", reason: "Tighter wording" })).toContain("queued for board review"); + const approvals = h.connection.db.prepare("select title from approvals where org_id = ? and kind = 'deliverable_review' order by created_at").all(h.orgId); + expect(approvals).toEqual([{ title: "Review policy: Policy" }, { title: "Review policy update: Policy" }]); + h.connection.close(); + }); +}); diff --git a/tests/unit/text-paging.test.ts b/tests/unit/text-paging.test.ts index c70d2057..b83a8ac1 100644 --- a/tests/unit/text-paging.test.ts +++ b/tests/unit/text-paging.test.ts @@ -53,6 +53,8 @@ describe("text paging", () => { test("bad ranges fail loudly", () => { expect(() => readPageArgs({ start_line: 0 })).toThrow('"start_line" must be a positive whole number.'); expect(() => readPageArgs({ line_count: 2.5 })).toThrow('"line_count" must be a positive whole number.'); + expect(() => readPageArgs({ search: 123 })).toThrow('"search" is required.'); + expect(() => readPageArgs({ search: " " })).toThrow('"search" is required.'); expect(() => pageText("a.md", "one", readPageArgs({ start_line: 3 }))).toThrow("a.md has only 1 lines."); }); });