From abfcbf48246927db4849d2963d23917ebca43960 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 18:35:39 -0400 Subject: [PATCH] Q114: goal audits use the org's own auditor model and see pointers, not the executor's account Co-Authored-By: Claude Opus 5.5 --- src/main/app/ipc-handlers.ts | 2 + .../database/migrations/087-goal-auditor.ts | 7 ++ src/main/database/schema.ts | 2 + src/main/services/agent-goal-tools.ts | 36 ++++---- src/main/services/agent-run-loop.ts | 1 - src/main/services/goal-audit-inputs.ts | 73 +++++++++++++++ src/main/services/goal-review-service.ts | 2 +- src/main/services/provider-response-runner.ts | 10 ++- src/preload/index.ts | 1 + src/renderer/src/i18n/zh.ts | 4 + src/renderer/src/pages/SettingsPage.tsx | 1 + .../src/pages/settings/AdvancedPanel.tsx | 8 ++ .../src/pages/settings/GoalAuditPanel.tsx | 42 +++++++++ src/shared/desktop-api.ts | 1 + src/shared/domain.ts | 3 + src/shared/ipc.ts | 1 + tests/integration/run-stop.test.ts | 7 +- tests/unit/agent-goal-tools.test.ts | 90 +++++++++++++++---- tests/unit/domain-services.test.ts | 2 +- 19 files changed, 252 insertions(+), 41 deletions(-) create mode 100644 src/main/database/migrations/087-goal-auditor.ts create mode 100644 src/main/services/goal-audit-inputs.ts create mode 100644 src/renderer/src/pages/settings/GoalAuditPanel.tsx diff --git a/src/main/app/ipc-handlers.ts b/src/main/app/ipc-handlers.ts index 83b3189e..24f308f2 100644 --- a/src/main/app/ipc-handlers.ts +++ b/src/main/app/ipc-handlers.ts @@ -30,6 +30,7 @@ import { SprintService } from "../services/sprint-service"; import { ComposioConnectionService } from "../services/composio/composio-connection-service"; import { ConnectorGrantsService } from "../services/composio/connector-grants"; import { approvalDrifts } from "../services/approval-drift"; +import { setGoalAuditor } from "../services/goal-audit-inputs"; import { IntegrationKeyStore } from "../services/integration-key-store"; import { importStagedSkills, stageBuiltinSkillsLibrary } from "../services/builtin-skills"; import { coalesce } from "./coalesce"; @@ -224,6 +225,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { }); ipcMain.handle(IPC_CHANNELS.updateProviderPolicy, (_event, input) => mutate(() => providers.updatePolicy(input))); ipcMain.handle(IPC_CHANNELS.setProviderPrices, (_event, input) => mutate(() => providers.setPrices(input))); + ipcMain.handle(IPC_CHANNELS.setGoalAuditor, (_event, input) => mutate(() => setGoalAuditor(deps.connection.db, input))); ipcMain.handle(IPC_CHANNELS.checkProviderHealth, async (_event, input) => { try { await providers.checkHealth(input.orgId, input.providerId); diff --git a/src/main/database/migrations/087-goal-auditor.ts b/src/main/database/migrations/087-goal-auditor.ts new file mode 100644 index 00000000..767ff1ff --- /dev/null +++ b/src/main/database/migrations/087-goal-auditor.ts @@ -0,0 +1,7 @@ +export const goalAuditorSchema = ` + alter table organizations add column audit_provider_id text; + alter table organizations add column audit_model_id text; + + insert into schema_migrations(version, name, applied_at) + values (87, 'goal_auditor', datetime('now')); +`; diff --git a/src/main/database/schema.ts b/src/main/database/schema.ts index a50504e7..e8268920 100644 --- a/src/main/database/schema.ts +++ b/src/main/database/schema.ts @@ -85,6 +85,7 @@ import { connectorAccountLabelsSchema } from "./migrations/083-connector-account import { connectorGrantLimitsSchema } from "./migrations/084-connector-grant-limits"; import { approvalSubjectSnapshotSchema } from "./migrations/085-approval-subject-snapshot"; import { heldDeliverablesSchema } from "./migrations/086-held-deliverables"; +import { goalAuditorSchema } from "./migrations/087-goal-auditor"; interface Migration { version: number; @@ -179,6 +180,7 @@ const MIGRATIONS: Migration[] = [ { version: 84, name: "connector_grant_limits", sql: connectorGrantLimitsSchema }, { version: 85, name: "approval_subject_snapshot", sql: approvalSubjectSnapshotSchema }, { version: 86, name: "held_deliverables", sql: heldDeliverablesSchema }, + { version: 87, name: "goal_auditor", sql: goalAuditorSchema }, ]; const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version; diff --git a/src/main/services/agent-goal-tools.ts b/src/main/services/agent-goal-tools.ts index e64e1b42..1c6f0e41 100644 --- a/src/main/services/agent-goal-tools.ts +++ b/src/main/services/agent-goal-tools.ts @@ -11,6 +11,7 @@ import type { ProviderService } from "./provider-service"; import { unreadableArguments, type AgentTurnMessage, type ToolCall, type ToolDefinition } from "./provider-tool-turn"; import { MAX_TOOL_CALLS_PER_TURN, throwIfRunStopped, untilStopped } from "./run-control"; import { heldWorkTitles } from "./held-deliverables"; +import { goalAuditor, resultPointers, type GoalAuditor, type ResultPointers } from "./goal-audit-inputs"; import { DOCUMENT_TOOLS, listDocumentsTool, readDocumentTool } from "./agent-document-tools"; const AUDIT_MAX_ITERATIONS = 6; @@ -47,12 +48,10 @@ export const GOAL_TOOLS: ToolDefinition[] = [ type: "object", additionalProperties: false, properties: { - evidence: { - type: "string", - description: "The concrete evidence that the goal is achieved: what exists, where it lives in the workspace, and what verification passed.", - }, + files: { type: "array", items: { type: "string" }, description: "Workspace paths of the files that hold the result." }, + documents: { type: "array", items: { type: "string" }, description: "Doc ids of the documents that hold the result." }, }, - required: ["evidence"], + required: [], }, }, ]; @@ -63,7 +62,6 @@ export interface GoalCompletionArgs { providers: Pick; providerId: string; workspaceId: string | null; - inputBudgetTokens: number; call: ToolCall; } @@ -82,13 +80,14 @@ export async function completeGoalTool(args: GoalCompletionArgs): Promise 0) return heldGoalRefusal(deps, goal); - const evidence = requireString(call.arguments.evidence, "evidence"); + const pointers = resultPointers(deps.db, ctx, call.arguments); + const auditor = goalAuditor(deps.db, ctx, args.providerId); - const verdict = await auditGoalCompletion(args, goal, evidence); + const verdict = await auditGoalCompletion(args, goal, pointers, auditor); if (!verdict.approved) { return `The independent audit rejected closing the goal: ${verdict.reason} The goal stays active — close that gap or create the tasks that will.`; } @@ -125,14 +124,15 @@ interface AuditVerdict { async function auditGoalCompletion( args: GoalCompletionArgs, goal: { title: string; description: string }, - evidence: string, + pointers: ResultPointers, + auditor: GoalAuditor, ): Promise { const workspace = args.ctx.workspace; // With the operator's file-access approval the auditor may build and test, but only in a throwaway copy. const scratch = workspace?.writeAccess ? copyWorkspace(workspace.localPath) : null; try { const auditWorkspace = workspace ? { ...workspace, localPath: scratch ?? workspace.localPath, writeAccess: scratch !== null } : null; - return await runAudit(args, goal, evidence, auditWorkspace); + return await runAudit(args, goal, pointers, auditor, auditWorkspace); } finally { if (scratch) rmSync(scratch, { recursive: true, force: true }); } @@ -153,7 +153,8 @@ function copyWorkspace(root: string): string { async function runAudit( args: GoalCompletionArgs, goal: { title: string; description: string }, - evidence: string, + pointers: ResultPointers, + auditor: GoalAuditor, workspace: AgentToolContext["workspace"], ): Promise { const canRun = workspace?.writeAccess === true; @@ -170,8 +171,10 @@ async function runAudit( "A goal is claimed complete. Decide whether the claim survives the evidence.", `Goal: ${goal.title}`, `Goal detail: ${goal.description}`, - "The executor's claim:", - evidence, + // The executor's own account of the work is never shown: only where the result lives. + "The executor points to these as the result. Verify them yourself:", + `Files: ${pointers.files.length > 0 ? pointers.files.join(", ") : "none"}`, + `Documents: ${pointers.documents.length > 0 ? pointers.documents.map((doc) => `${doc.title} (doc_id=${doc.id})`).join(", ") : "none"}`, `Inspect the work before deciding: list_files and read_file for the workspace, list_documents and read_document for the library. An approval counts only after you have read some of it. ${canRun ? "run_command runs in a throwaway copy of the workspace: build and run the tests yourself instead of trusting claims that they pass; nothing you do there reaches the real workspace." : "You cannot run commands here, so a claim that something was run or passes is unverified unless the workspace holds its results."} Judge outcomes, not artifacts about outcomes: plans, specs, status reports, and skeletons do not make a goal achieved; working deliverables do. If the goal promises software, look for the actual implementation, not documents describing it.`, "Keep your reasoning apart from your decision. End your final message with the decision alone on its last line: by itself if the goal is genuinely achieved, or followed by one short sentence naming the decisive gap. Use no marker anywhere else.", ].join("\n\n"), @@ -179,11 +182,12 @@ async function runAudit( const instructions = "You are the independent completion auditor. You did not do the work and owe its author nothing; your only loyalty is to what actually exists. Be strict: an unverifiable claim is a false claim."; for (let iteration = 1; iteration <= AUDIT_MAX_ITERATIONS; iteration += 1) { - fitContextBudget(instructions, messages, auditTools, args.inputBudgetTokens, iteration); + fitContextBudget(instructions, messages, auditTools, auditor.inputBudgetTokens, iteration); throwIfRunStopped(); const turn = await untilStopped(args.providers.runToolTurn({ orgId: args.ctx.orgId, - providerId: args.providerId, + providerId: auditor.providerId, + model: auditor.model, workspaceId: args.workspaceId, instructions, messages, diff --git a/src/main/services/agent-run-loop.ts b/src/main/services/agent-run-loop.ts index 95d1c63f..ce03ce86 100644 --- a/src/main/services/agent-run-loop.ts +++ b/src/main/services/agent-run-loop.ts @@ -137,7 +137,6 @@ export async function runAgentLoop(deps: AgentRunLoopDeps, input: AgentRunLoopIn providers: deps.providers, providerId: input.providerId, workspaceId: input.workspaceId, - inputBudgetTokens: input.inputBudgetTokens, call, })); } else { diff --git a/src/main/services/goal-audit-inputs.ts b/src/main/services/goal-audit-inputs.ts new file mode 100644 index 00000000..02d07308 --- /dev/null +++ b/src/main/services/goal-audit-inputs.ts @@ -0,0 +1,73 @@ +import { existsSync } from "node:fs"; +import type { DatabaseSync } from "node:sqlite"; +import { optionalStringArray } from "./agent-tool-args"; +import { inputBudgetTokens } from "./context-budget"; +import type { AgentToolContext } from "./agent-tools"; +import { resolveWorkspacePath } from "./files/workspace-path"; +import type { JsonRecord } from "./provider-http"; +import { currentRunId } from "./run-control"; +import { effectiveModel } from "./task-run-preparation"; + +const AUDITOR_SETTING = "Settings > Advanced > Goal audit"; + +export interface GoalAuditor { + providerId: string; + model: string; + inputBudgetTokens: number; +} + +/** Where the result lives, as the executor points to it; the auditor gets these, never the executor's account of the work. */ +export interface ResultPointers { + files: string[]; + documents: Array<{ id: string; title: string }>; +} + +/** The model that audits goals: the org's own choice, never the executor's model, so the two do not share one model's blind spots. */ +export function goalAuditor(db: DatabaseSync, ctx: AgentToolContext, executorProviderId: string): GoalAuditor { + const org = db.prepare("select audit_provider_id, audit_model_id from organizations where id = ?") + .get(ctx.orgId) as { audit_provider_id: string | null; audit_model_id: string | null }; + if (!org.audit_provider_id || !org.audit_model_id) { + throw new Error(`Goal audits need an auditor model of their own, different from the executor's. Ask the board to set one in ${AUDITOR_SETTING}.`); + } + const runId = currentRunId(); + const pinned = runId ? (db.prepare("select run_model from task_runs where id = ?").get(runId) as { run_model: string | null } | undefined)?.run_model ?? null : null; + const providerModel = (db.prepare("select model from providers where id = ?").get(executorProviderId) as { model: string | null } | undefined)?.model ?? null; + const executorModel = pinned ?? effectiveModel(ctx.agent.modelId, providerModel); + if (org.audit_provider_id === executorProviderId && org.audit_model_id === executorModel) { + throw new Error(`The goal auditor is set to the executor's own model (${executorModel}); the board must choose a different one in ${AUDITOR_SETTING}.`); + } + const provider = db.prepare("select context_window_tokens from providers where id = ? and org_id = ?") + .get(org.audit_provider_id, ctx.orgId) as { context_window_tokens: number } | undefined; + if (!provider) throw new Error(`The goal auditor's provider no longer exists; the board must choose another in ${AUDITOR_SETTING}.`); + // The audit fits the auditor's own context window, not the executor's. + return { providerId: org.audit_provider_id, model: org.audit_model_id, inputBudgetTokens: inputBudgetTokens(provider.context_window_tokens) }; +} + +/** The board's choice of goal auditor: a provider of this org and a model name. */ +export function setGoalAuditor(db: DatabaseSync, input: { orgId: string; providerId: string; modelId: string }): void { + const provider = db.prepare("select id from providers where id = ? and org_id = ?").get(input.providerId, input.orgId); + if (!provider) throw new Error("The goal auditor's provider was not found."); + const model = input.modelId.trim(); + if (!model) throw new Error("The goal auditor needs a model."); + db.prepare("update organizations set audit_provider_id = ?, audit_model_id = ? where id = ?").run(input.providerId, model, input.orgId); + console.info(`[goal-audit] org ${input.orgId} audits goals with ${model} on ${input.providerId}`); +} + +/** Checks the executor's pointers before any audit spend: every file in the workspace and every document must exist. */ +export function resultPointers(db: DatabaseSync, ctx: AgentToolContext, args: JsonRecord): ResultPointers { + const files = optionalStringArray(args.files, "files"); + const documentIds = optionalStringArray(args.documents, "documents"); + if (files.length + documentIds.length === 0) { + throw new Error("Point to the result: files (workspace paths) and/or documents (doc ids) that hold it."); + } + for (const file of files) { + if (!ctx.workspace) throw new Error("No workspace is bound to this task, so files cannot be pointed to; point to documents instead."); + if (!existsSync(resolveWorkspacePath(ctx.workspace.localPath, file).absolute)) throw new Error(`There is no file ${file} in the workspace.`); + } + const documents = documentIds.map((id) => { + const document = db.prepare("select id, title from documents where id = ? and org_id = ?").get(id, ctx.orgId) as { id: string; title: string } | undefined; + if (!document) throw new Error(`There is no document ${id}.`); + return document; + }); + return { files, documents }; +} diff --git a/src/main/services/goal-review-service.ts b/src/main/services/goal-review-service.ts index 112ddb36..818532a3 100644 --- a/src/main/services/goal-review-service.ts +++ b/src/main/services/goal-review-service.ts @@ -130,7 +130,7 @@ function goalReviewDescription(goalTitle: string): string { `Every task under the goal "${goalTitle.trim()}" is finished, but the goal itself is still open. Decide what happens next — this is a checkpoint, not a formality.`, "First gather evidence: list_files and read_file the workspace, and read what the team reported. Plans, specs, and status documents are not the product. If the goal promises working software, the proof is code in the workspace that builds and passes its tests (run_command).", "Then take exactly one of these actions before closing this task:", - "1. The goal is genuinely achieved -> call complete_goal with your evidence. An independent auditor inspects the workspace before the goal actually closes, so claims without substance will bounce.", + "1. The goal is genuinely achieved -> call complete_goal with the files and documents that hold the result. An independent auditor inspects the workspace before the goal actually closes, so claims without substance will bounce.", "2. It is not achieved -> create the next wave of tasks (create_task, assigned by name). Implementation work must set requires_filesystem: true so the assignee can change code and run commands. Documents alone must not close the next wave either.", ].join("\n\n"); } diff --git a/src/main/services/provider-response-runner.ts b/src/main/services/provider-response-runner.ts index 56f02a0d..0ca880d4 100644 --- a/src/main/services/provider-response-runner.ts +++ b/src/main/services/provider-response-runner.ts @@ -114,6 +114,8 @@ export class ProviderResponseRunner { instructions: string | null; messages: AgentTurnMessage[]; tools: ToolDefinition[]; + /** A model chosen for this call (a goal auditor's), outside the session's pinned model and account. */ + model?: string; }): Promise { const provider = loadConfiguredProvider(this.deps.db, input.orgId, input.providerId); const workspaceId = this.normalizeWorkspaceId(input.orgId, input.workspaceId ?? null); @@ -128,12 +130,14 @@ export class ProviderResponseRunner { const pins = taskRunId ? this.deps.db.prepare("select run_model, run_credential_id from task_runs where id = ?").get(taskRunId) as { run_model: string | null; run_credential_id: string | null } : null; - if (pins?.run_credential_id && pins.run_credential_id !== provider.credential_id) { + const chosen = input.model ?? null; + if (!chosen && pins?.run_credential_id && pins.run_credential_id !== provider.credential_id) { throw new Error("The provider's account was reconnected during this session; the next session continues with the new account."); } // Every turn of a session calls the model the session started with (checked then), whatever the configuration says now. - if (!pins?.run_model) assertModelSelectable(provider.provider_key, agent?.model_id ?? null, provider.model); - const model = pins?.run_model ?? effectiveModel(agent?.model_id ?? null, provider.model); + if (chosen) assertModelSelectable(provider.provider_key, chosen, provider.model); + else if (!pins?.run_model) assertModelSelectable(provider.provider_key, agent?.model_id ?? null, provider.model); + const model = chosen ?? pins?.run_model ?? effectiveModel(agent?.model_id ?? null, provider.model); if (!model) throw new Error("Provider model is required."); const owner: ProviderRow & { model: string } = { ...provider, model }; const turn = this.deps.toolTurn ?? selectToolTurn(provider.provider_key); diff --git a/src/preload/index.ts b/src/preload/index.ts index 89d4a30f..62f0eb07 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -62,6 +62,7 @@ const api: DesktopApi = { checkProviderModel: (input) => invoke(IPC_CHANNELS.checkProviderModel, input), updateProviderPolicy: (input) => invoke(IPC_CHANNELS.updateProviderPolicy, input), setProviderPrices: (input) => invoke(IPC_CHANNELS.setProviderPrices, input), + setGoalAuditor: (input) => invoke(IPC_CHANNELS.setGoalAuditor, input), openExternalUrl: (url) => invoke(IPC_CHANNELS.openExternalUrl, url), createProviderResponse: (input) => invoke(IPC_CHANNELS.createProviderResponse, input), streamProviderResponse: (input) => invoke(IPC_CHANNELS.streamProviderResponse, input), diff --git a/src/renderer/src/i18n/zh.ts b/src/renderer/src/i18n/zh.ts index 4710ec93..c1e21bfb 100644 --- a/src/renderer/src/i18n/zh.ts +++ b/src/renderer/src/i18n/zh.ts @@ -390,6 +390,10 @@ export const zh: Record = { "Replaced by an edit": "已因编辑被替换", "Changed since approval": "批准后已改动", "Removed since approval": "批准后已删除", + "Goal audit": "目标审查", + "An independent model checks every goal before it closes. Pick one your agents do not use, ideally from another provider.": "每个目标关闭前都由一个独立模型检查。请选择 Agent 不使用的模型,最好来自另一家服务商。", + "No auditor is set, so goals cannot be closed yet.": "尚未设置审查模型,目标暂时无法关闭。", + "Choose a provider": "选择服务商", "Could not check approved content: {message}": "无法核对已批准内容:{message}", "Revoke {name}": "撤销 {name}", "Grant {name} to": "把 {name} 授予", diff --git a/src/renderer/src/pages/SettingsPage.tsx b/src/renderer/src/pages/SettingsPage.tsx index d9bc89de..df420934 100644 --- a/src/renderer/src/pages/SettingsPage.tsx +++ b/src/renderer/src/pages/SettingsPage.tsx @@ -63,6 +63,7 @@ export function SettingsPage({ snapshot, actions }: PageProps) { {tab === "advanced" && ( )} +
+ +
+
diff --git a/src/renderer/src/pages/settings/GoalAuditPanel.tsx b/src/renderer/src/pages/settings/GoalAuditPanel.tsx new file mode 100644 index 00000000..66cf5921 --- /dev/null +++ b/src/renderer/src/pages/settings/GoalAuditPanel.tsx @@ -0,0 +1,42 @@ +import { useState, type FormEvent } from "react"; +import type { OrganizationRecord, ProviderRecord } from "../../../../shared/domain"; +import type { SnapshotActions } from "../../app/useSnapshot"; +import { Button, Field } from "../../shared/ui"; +import { t } from "../../i18n/i18n"; + +/** The model that audits goal completion; it must differ from the executor's so the two do not share one model's blind spots. */ +export function GoalAuditPanel({ organization, providers, actions }: { organization: OrganizationRecord; providers: ProviderRecord[]; actions: SnapshotActions }) { + const [providerId, setProviderId] = useState(organization.auditProviderId ?? ""); + const [modelId, setModelId] = useState(organization.auditModelId ?? ""); + const [error, setError] = useState(null); + const save = (event: FormEvent) => { + event.preventDefault(); + setError(null); + void actions.run(() => window.agentCompany.setGoalAuditor({ orgId: organization.id, providerId, modelId })) + .catch((reason: unknown) => setError(reason instanceof Error ? reason.message : String(reason))); + }; + return ( +
+
{t("Goal audit")}
+

+ {t("An independent model checks every goal before it closes. Pick one your agents do not use, ideally from another provider.")} +

+ {!organization.auditModelId && ( +

{t("No auditor is set, so goals cannot be closed yet.")}

+ )} +
+ + + + + setModelId(event.target.value)} placeholder="claude-opus-5-5" required /> + + +
+ {error &&

{error}

} +
+ ); +} diff --git a/src/shared/desktop-api.ts b/src/shared/desktop-api.ts index 8d7e87e1..de10e87c 100644 --- a/src/shared/desktop-api.ts +++ b/src/shared/desktop-api.ts @@ -172,6 +172,7 @@ export interface DesktopApi { checkProviderModel(input: { orgId: string; providerId: string; model: string }): Promise>; updateProviderPolicy(input: UpdateProviderPolicyInput): Promise>; setProviderPrices(input: SetProviderPricesInput): Promise>; + setGoalAuditor(input: { orgId: string; providerId: string; modelId: string }): Promise>; openExternalUrl(url: string): Promise>; createProviderResponse(input: CreateProviderResponseInput): Promise>; streamProviderResponse(input: StreamProviderResponseInput): Promise>; diff --git a/src/shared/domain.ts b/src/shared/domain.ts index c9e68d97..816894c5 100644 --- a/src/shared/domain.ts +++ b/src/shared/domain.ts @@ -45,6 +45,9 @@ export interface OrganizationRecord { executionMode: string; autoApproveHires: boolean; reviewDeliverables: boolean; + /** The provider and model that audit goal completion, never the executor's own; null until the board sets them. */ + auditProviderId: string | null; + auditModelId: string | null; createdAt: string; } diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index 8d14bd5f..1220e728 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -35,6 +35,7 @@ export const IPC_CHANNELS = { checkProviderModel: "agentcompany:check-provider-model", updateProviderPolicy: "agentcompany:update-provider-policy", setProviderPrices: "agentcompany:set-provider-prices", + setGoalAuditor: "agentcompany:set-goal-auditor", openExternalUrl: "agentcompany:open-external-url", createProviderResponse: "agentcompany:create-provider-response", streamProviderResponse: "agentcompany:stream-provider-response", diff --git a/tests/integration/run-stop.test.ts b/tests/integration/run-stop.test.ts index 2c15c2ed..450f33af 100644 --- a/tests/integration/run-stop.test.ts +++ b/tests/integration/run-stop.test.ts @@ -1,3 +1,4 @@ +import { DocumentService } from "../../src/main/services/document-service"; import { randomUUID } from "node:crypto"; import { createServer } from "node:http"; import type { AddressInfo } from "node:net"; @@ -73,10 +74,14 @@ describe("stopping runs", () => { stopRun(runId); return { text: "Verified. ", toolCalls: [], rawResponseJson: "{}" }; } - return { text: "Closing.", toolCalls: [{ id: "c1", name: "complete_goal", arguments: { goal_id: goalId, evidence: "Shipped." } }], rawResponseJson: "{}" }; + return { text: "Closing.", toolCalls: [{ id: "c1", name: "complete_goal", arguments: { documents: [docId] } }], rawResponseJson: "{}" }; }; const { connection, execution, orgId, claim } = company(turn); const goalId = (connection.db.prepare("select id from goals where org_id = ?").get(orgId) as { id: string }).id; + const ceoId = (connection.db.prepare("select id from agents where org_id = ? and role_level = 'c_suite'").get(orgId) as { id: string }).id; + const docId = connection.transaction(() => new DocumentService(connection.db).create({ orgId, title: "MVP", docType: "report", content: "Live.", authorAgentId: ceoId, reviewerAgentId: null, projectId: null, goalId, taskId: null })); + // The org audits goals with a model of its own. + connection.db.prepare("update organizations set audit_provider_id = (select id from providers where org_id = ? and provider_key = 'openai'), audit_model_id = 'gpt-5.4-mini' where id = ?").run(orgId, orgId); runId = claim(); await expect(execution.executeRun(orgId, runId)).rejects.toThrow(RUN_STOPPED_MESSAGE); expect(connection.db.prepare("select status from goals where id = ?").get(goalId)).toEqual({ status: "active" }); diff --git a/tests/unit/agent-goal-tools.test.ts b/tests/unit/agent-goal-tools.test.ts index 14928947..7e38a75b 100644 --- a/tests/unit/agent-goal-tools.test.ts +++ b/tests/unit/agent-goal-tools.test.ts @@ -1,3 +1,4 @@ +import { setGoalAuditor } from "../../src/main/services/goal-audit-inputs"; import { DocumentService } from "../../src/main/services/document-service"; import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs"; import { randomUUID } from "node:crypto"; @@ -59,6 +60,7 @@ function createHarness(): Harness { modelProviderId: providerId, modelId: "gpt-5.4", budgetCents: 10000, managerAgentId: ceoId, roleLevel: "staff", }); goalId = organization.createGoal({ orgId, title: "Ship it", description: "For real.", ownerAgentId: ceoId }); + connection.db.prepare("update organizations set audit_provider_id = ?, audit_model_id = 'gpt-5.4-mini' where id = ?").run(providerId, orgId); }); return { connection, deps: { db: connection.db, organization, chat: new ChatService(connection.db) }, orgId, ceoId, staffId, goalId, providerId }; } @@ -67,7 +69,7 @@ function call(args: Record): ToolCall { return { id: `call_${randomUUID()}`, name: "complete_goal", arguments: args }; } -function argsFor(h: Harness, agentId: string, auditor: Auditor, toolCall: ToolCall, inputBudgetTokens = 112_000): GoalCompletionArgs { +function argsFor(h: Harness, agentId: string, auditor: Auditor, toolCall: ToolCall): GoalCompletionArgs { const ctx: AgentToolContext = { orgId: h.orgId, agent: { id: agentId, modelProviderId: h.providerId, modelId: "gpt-5.4" }, @@ -75,7 +77,7 @@ function argsFor(h: Harness, agentId: string, auditor: Auditor, toolCall: ToolCa taskGoalId: h.goalId, taskId: null, sourceThreadId: null, workspace: { id: "wsp_audit", localPath: workspaceRoot, writeAccess: true }, }; - return { deps: h.deps, ctx, providers: { runToolTurn: auditor }, providerId: h.providerId, workspaceId: null, inputBudgetTokens, call: toolCall }; + return { deps: h.deps, ctx, providers: { runToolTurn: auditor }, providerId: h.providerId, workspaceId: null, call: toolCall }; } function goalStatus(h: Harness): string { @@ -100,7 +102,7 @@ describe("complete_goal with independent audit", () => { return turnOf("The implementation exists and runs.\n"); }; - const readOnly = argsFor(h, h.ceoId, auditor, call({ evidence: "src/index.ts implements the app and runs." })); + const readOnly = argsFor(h, h.ceoId, auditor, call({ files: ["src/index.ts"] })); readOnly.ctx.workspace = { ...readOnly.ctx.workspace!, writeAccess: false }; const output = await completeGoalTool(readOnly); expect(output).toContain("achieved"); @@ -130,7 +132,7 @@ describe("complete_goal with independent audit", () => { } return turnOf("Tests pass when run.\n"); }; - const output = await completeGoalTool(argsFor(h, h.ceoId, auditor, call({ evidence: "All tests pass." }))); + const output = await completeGoalTool(argsFor(h, h.ceoId, auditor, call({ files: ["src/index.ts"] }))); expect(output).toContain("achieved"); expect(commandOutput).toContain("all 3 tests passed"); expect(ranIn).toContain("built.txt"); @@ -144,7 +146,8 @@ describe("complete_goal with independent audit", () => { test("a workspace that cannot be copied fails the audit and leaves no copy behind", async () => { const h = createHarness(); - const args = argsFor(h, h.ceoId, async () => turnOf(""), call({ evidence: "done" })); + const docId = h.connection.transaction(() => new DocumentService(h.connection.db).create({ orgId: h.orgId, title: "Report", docType: "report", content: "x", authorAgentId: h.ceoId, reviewerAgentId: null, projectId: null, goalId: h.goalId, taskId: null })); + const args = argsFor(h, h.ceoId, async () => turnOf(""), call({ documents: [docId] })); args.ctx.workspace = { ...args.ctx.workspace!, localPath: join(tmpdir(), `ac-gone-${randomUUID()}`) }; // A private temp folder for this audit, so the check sees only its own copies. const base = mkdtempSync(join(tmpdir(), "ac-tmpbase-")); @@ -167,7 +170,7 @@ describe("complete_goal with independent audit", () => { test("a disapproving auditor keeps the goal open and returns the gap to the caller", async () => { const h = createHarness(); const auditor = async (): Promise => turnOf(" Only planning documents exist; there is no implementation."); - const output = await completeGoalTool(argsFor(h, h.ceoId, auditor, call({ evidence: "We wrote a very thorough plan." }))); + const output = await completeGoalTool(argsFor(h, h.ceoId, auditor, call({ files: ["src/index.ts"] }))); expect(output).toContain("audit rejected"); expect(output).toContain("no implementation"); expect(goalStatus(h)).toBe("active"); @@ -187,7 +190,7 @@ describe("complete_goal with independent audit", () => { hold(); return turnOf(""); }; - const output = await completeGoalTool(argsFor(h, h.ceoId, auditor, call({ evidence: "src/index.ts works" }))); + const output = await completeGoalTool(argsFor(h, h.ceoId, auditor, call({ files: ["src/index.ts"] }))); expect(output).toBe('ERROR: Goal "Ship it" has work awaiting board review (Launch memo); it is not written yet, so the goal cannot be achieved.'); expect(goalStatus(h)).toBe("active"); h.connection.close(); @@ -201,7 +204,7 @@ describe("complete_goal with independent audit", () => { prompts.push(last && "text" in last ? String(last.text) : ""); return turnOf("Looks done.\n"); }; - const output = await completeGoalTool(argsFor(h, h.ceoId, auditor, call({ evidence: "Trust me." }))); + const output = await completeGoalTool(argsFor(h, h.ceoId, auditor, call({ files: ["src/index.ts"] }))); expect(output).toContain("audit rejected"); expect(goalStatus(h)).toBe("active"); expect(prompts[1]).toBe("You approved without reading any evidence. Open the work itself with read_file or read_document, then decide again."); @@ -230,7 +233,7 @@ describe("complete_goal with independent audit", () => { histories.push([...input.messages]); return turns.shift()!; }; - const output = await completeGoalTool(argsFor(h, h.ceoId, auditor, call({ evidence: "See the launch report." }))); + const output = await completeGoalTool(argsFor(h, h.ceoId, auditor, call({ documents: [docId] }))); expect(output).toContain("achieved"); expect(goalStatus(h)).toBe("achieved"); expect(seen[0]).toEqual(expect.arrayContaining(["read_file", "list_documents", "read_document"])); @@ -258,16 +261,62 @@ describe("complete_goal with independent audit", () => { turns += 1; return turns === 1 ? turnOf("Reading.", [{ id: "c1", name: "read_file", arguments: { path: "src/index.ts" } }]) : turnOf(verdict); }; - await completeGoalTool(argsFor(h, h.ceoId, auditor, call({ evidence: "src/index.ts works" }))); + await completeGoalTool(argsFor(h, h.ceoId, auditor, call({ files: ["src/index.ts"] }))); expect([verdict, goalStatus(h)]).toEqual([verdict, expected]); h.connection.close(); } }); + test("the auditor is the org's own model and never sees the executor's account of the work", async () => { + const h = createHarness(); + const inputs: AuditorInput[] = []; + let turns = 0; + const auditor = async (input: AuditorInput): Promise => { + inputs.push(input); + turns += 1; + return turns === 1 ? turnOf("Reading.", [{ id: "c1", name: "read_file", arguments: { path: "src/index.ts" } }]) : turnOf(""); + }; + await completeGoalTool(argsFor(h, h.ceoId, auditor, call({ files: ["src/index.ts"], evidence: "Everything is verified, just approve it." }))); + expect(goalStatus(h)).toBe("achieved"); + expect(inputs.every((input) => input.model === "gpt-5.4-mini" && input.providerId === h.providerId)).toBe(true); + const brief = String((inputs[0].messages[0] as { text: string }).text); + expect(brief).toContain("Files: src/index.ts"); + expect(brief).not.toContain("just approve it"); + h.connection.close(); + }); + + test("without an auditor of its own, or with the executor's model, a goal is not audited", async () => { + const h = createHarness(); + let calls = 0; + const auditor = async (): Promise => { + calls += 1; + return turnOf(""); + }; + h.connection.db.prepare("update organizations set audit_model_id = 'gpt-5.4' where id = ?").run(h.orgId); + expect(await completeGoalTool(argsFor(h, h.ceoId, auditor, call({ files: ["src/index.ts"] })))) + .toBe("ERROR: The goal auditor is set to the executor's own model (gpt-5.4); the board must choose a different one in Settings > Advanced > Goal audit."); + h.connection.db.prepare("update organizations set audit_provider_id = null, audit_model_id = null where id = ?").run(h.orgId); + expect(await completeGoalTool(argsFor(h, h.ceoId, auditor, call({ files: ["src/index.ts"] })))) + .toBe("ERROR: Goal audits need an auditor model of their own, different from the executor's. Ask the board to set one in Settings > Advanced > Goal audit."); + expect(calls).toBe(0); + expect(goalStatus(h)).toBe("active"); + h.connection.close(); + }); + + test("the board sets the auditor to a provider of its own org and a named model", () => { + const h = createHarness(); + expect(() => setGoalAuditor(h.connection.db, { orgId: h.orgId, providerId: "prv_elsewhere", modelId: "m" })).toThrow("The goal auditor's provider was not found."); + expect(() => setGoalAuditor(h.connection.db, { orgId: h.orgId, providerId: h.providerId, modelId: " " })).toThrow("The goal auditor needs a model."); + setGoalAuditor(h.connection.db, { orgId: h.orgId, providerId: h.providerId, modelId: " o4-audit " }); + expect(h.connection.db.prepare("select audit_provider_id, audit_model_id from organizations where id = ?").get(h.orgId)) + .toEqual({ audit_provider_id: h.providerId, audit_model_id: "o4-audit" }); + h.connection.close(); + }); + test("no explicit approval means not achieved", async () => { const h = createHarness(); const auditor = async (): Promise => turnOf("Looks plausible I guess."); - const output = await completeGoalTool(argsFor(h, h.ceoId, auditor, call({ evidence: "Trust me." }))); + const output = await completeGoalTool(argsFor(h, h.ceoId, auditor, call({ files: ["src/index.ts"] }))); expect(output).toContain("audit rejected"); expect(goalStatus(h)).toBe("active"); h.connection.close(); @@ -282,11 +331,13 @@ describe("complete_goal with independent audit", () => { }; // Staff cannot complete a goal they do not own. - expect(await completeGoalTool(argsFor(h, h.staffId, auditor, call({ evidence: "done" })))).toContain("ERROR"); - // Evidence is mandatory. - expect(await completeGoalTool(argsFor(h, h.ceoId, auditor, call({})))).toContain("ERROR"); + expect(await completeGoalTool(argsFor(h, h.staffId, auditor, call({ files: ["src/index.ts"] })))).toContain("ERROR"); + // Pointing to the result is mandatory, and what is pointed to must exist. + expect(await completeGoalTool(argsFor(h, h.ceoId, auditor, call({})))).toBe("ERROR: Point to the result: files (workspace paths) and/or documents (doc ids) that hold it."); + expect(await completeGoalTool(argsFor(h, h.ceoId, auditor, call({ files: ["src/missing.ts"] })))).toBe("ERROR: There is no file src/missing.ts in the workspace."); + expect(await completeGoalTool(argsFor(h, h.ceoId, auditor, call({ documents: ["doc_nope"] })))).toBe("ERROR: There is no document doc_nope."); // A task without a goal has nothing to complete. - const detached = argsFor(h, h.ceoId, auditor, call({ evidence: "done" })); + const detached = argsFor(h, h.ceoId, auditor, call({ files: ["src/index.ts"] })); detached.ctx.taskGoalId = null; expect(await completeGoalTool(detached)).toContain("ERROR"); @@ -294,9 +345,9 @@ describe("complete_goal with independent audit", () => { expect(goalStatus(h)).toBe("active"); // Once achieved, a second attempt reports the state instead of re-auditing. - await completeGoalTool(argsFor(h, h.ceoId, auditor, call({ evidence: "src/index.ts works" }))); + await completeGoalTool(argsFor(h, h.ceoId, auditor, call({ files: ["src/index.ts"] }))); expect(goalStatus(h)).toBe("achieved"); - expect(await completeGoalTool(argsFor(h, h.ceoId, auditor, call({ evidence: "again" })))).toContain("already achieved"); + expect(await completeGoalTool(argsFor(h, h.ceoId, auditor, call({ files: ["src/index.ts"] })))).toContain("already achieved"); expect(auditorCalls).toBe(2); h.connection.close(); }); @@ -422,7 +473,10 @@ describe("company_status", () => { calls += 1; return { text: "", toolCalls: [], rawResponseJson: "{}", requestId: "r" }; }; - const output = await completeGoalTool(argsFor(h, h.ceoId, auditor, call({ evidence: "证".repeat(5_000) }), 3_000)); + h.connection.db.prepare("update goals set description = ? where id = ?").run("证".repeat(5_000), h.goalId); + // The auditor's own provider sets the window: 19,000 tokens leave 3,000 for input. + h.connection.db.prepare("update providers set context_window_tokens = 19000 where id = ?").run(h.providerId); + const output = await completeGoalTool(argsFor(h, h.ceoId, auditor, call({ files: ["src/index.ts"] }))); expect(output).toMatch(/^ERROR: This run needs ~\d+ input tokens but the provider's context window leaves 3000/); expect(calls).toBe(0); expect(goalStatus(h)).toBe("active"); diff --git a/tests/unit/domain-services.test.ts b/tests/unit/domain-services.test.ts index c0cac246..3c17b6ef 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, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86]); + 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, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87]); connection.close(); });