From 612a7601524526ca70f6ee723ac086b5d0adc04b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Wed, 22 Apr 2026 16:38:59 -0400 Subject: [PATCH] fix(runtime): move team building policy into tools --- src/main/orchestration/initial-user-turn.ts | 21 +-- .../orchestration/internal-tool-selector.ts | 39 +++-- src/main/orchestration/types.ts | 7 +- .../ceo-modes/awaiting-hire.en.md | 2 +- .../ceo-modes/team-building.en.md | 25 +--- src/main/worker/runtime/helpers.ts | 5 +- .../runtime/internal-tools/hire-agent-tool.ts | 2 +- .../ceo-phased-prompts.spec.ts | 10 +- tests/unit/internal-tool-selector.test.ts | 135 ++++++++++++++++++ 9 files changed, 184 insertions(+), 62 deletions(-) create mode 100644 tests/unit/internal-tool-selector.test.ts diff --git a/src/main/orchestration/initial-user-turn.ts b/src/main/orchestration/initial-user-turn.ts index f88573ff..af9a9b76 100644 --- a/src/main/orchestration/initial-user-turn.ts +++ b/src/main/orchestration/initial-user-turn.ts @@ -54,22 +54,9 @@ export function resolveInitialUserTurn( // CEO / team-building semantic cue (C4 part 4/5). // - // The pre-C4 version of this block hard-coded the tool name (`bash`), - // the runtime (`node -e ...`), the API paths (`/api/companies/:id/ - // agents` + `/api/companies/:id/approvals`), the call count (3-5), - // and the imperative tone ("Start the first POST NOW. Do not write - // a plan document."). Every one of those was an implementation - // detail leaking into the LLM turn — when pi-agent-core swapped - // `bash`, when the HTTP path moved, or when the number policy shifted, - // the prompt silently drifted from reality and the CEO stopped filing - // hires. - // - // The new cue is deliberately short. The Team Building Phase - // operating instructions (see `ceo-modes/team-building.en.md`) own - // the SOP; this turn just points at it and tells the CEO to execute - // via the `hire_agent` and `file_approval` tools that C4 part 3/5 - // registered on its agent. No tool names, no HTTP endpoints — the - // tool surface is the contract. + // The turn names the state and points at the mode contract. The action + // policy lives in the tool schema and the CEO-mode instructions so model + // swaps do not depend on an imperative one-off user sentence. if (trigger === "timer" || trigger === "bootstrap" || trigger === "app_restart_resume") { const cue = tryResolveCeoTeamBuildingCue({ agentId, companyId, db: deps.db }); if (cue !== null) return cue; @@ -102,7 +89,7 @@ function tryResolveCeoTeamBuildingCue(params: { if (activeReports.length > 0) return null; return [ "This wake: your team is empty.", - "File 3-5 hire_agent requests and 1 approve_ceo_strategy via your assigned tools. Execute now, do not write a plan document.", + "Follow the Team Building operating instructions using the tools exposed for this run, then return.", ].join("\n\n"); } catch (error) { logAppError( diff --git a/src/main/orchestration/internal-tool-selector.ts b/src/main/orchestration/internal-tool-selector.ts index 1cea3686..635dbebf 100644 --- a/src/main/orchestration/internal-tool-selector.ts +++ b/src/main/orchestration/internal-tool-selector.ts @@ -9,16 +9,12 @@ * anyone with org-management authority) receives `hire_agent`. A * manager hiring an IC is a legitimate flow. * - An agent that `canFileCeoStrategy` (genuine CEO: CEO-equivalent - * role + `reportsTo === null`) receives `file_approval` so the - * `approve_ceo_strategy` flow works without the HTTP shim. Peer - * agents use the HTTP path until the file_approval surface is - * generalised. + * role + `reportsTo === null`) receives `file_approval` after the + * initial team-building phase has produced at least one hire. * - * The two predicates are intentionally orthogonal — a VP with hiring - * authority should not inherit CEO-strategy approval scope, and a CEO - * with a virtual superior inserted above them should not silently - * lose strategy scope either (guarded by `canFileCeoStrategy`'s role - * check, not just `reportsTo`). + * The two predicates are intentionally orthogonal: a VP with hiring + * authority receives hiring scope, while CEO-strategy approval remains + * pinned to the CEO role and the current org phase. * * No hedging, no sentinel values: if the DB lookup throws the caller * is already in a broken state and should not silently fall through @@ -39,8 +35,31 @@ export function resolveInternalToolsForAgent( if (canRequestHire(db, agent.companyId, agent.id)) { tools.push("hire_agent"); } - if (canFileCeoStrategy(db, agent.companyId, agent.id)) { + if (canFileCeoStrategy(db, agent.companyId, agent.id) && !isInitialTeamBuildingPhase(db, agent)) { tools.push("file_approval"); } return tools; } + +function isInitialTeamBuildingPhase(db: AppDatabase, agent: AgentRecord): boolean { + if (!canFileCeoStrategy(db, agent.companyId, agent.id)) return false; + + const directReports = db.agents.getDirectReports(agent.id, agent.companyId); + const activeReports = directReports.filter( + (report) => report.status !== "pending_approval" && report.status !== "terminated", + ); + const pendingReportRows = directReports.filter((report) => report.status === "pending_approval"); + const pendingHireApprovals = db.approvalsMod + .listCompanyApprovalsByType(agent.companyId, "hire_agent") + .filter((approval) => approval.state === "pending"); + const pendingStrategyApprovals = db.approvalsMod + .listCompanyApprovalsByType(agent.companyId, "approve_ceo_strategy") + .filter((approval) => approval.state === "pending"); + + return ( + activeReports.length === 0 && + pendingReportRows.length === 0 && + pendingHireApprovals.length === 0 && + pendingStrategyApprovals.length === 0 + ); +} diff --git a/src/main/orchestration/types.ts b/src/main/orchestration/types.ts index 5e0d0921..8d751e60 100644 --- a/src/main/orchestration/types.ts +++ b/src/main/orchestration/types.ts @@ -48,10 +48,9 @@ export interface EnqueueRunPayload { initialUserTurn: string; /** * Internal tools main decides to inject for this specific run (C4). - * Currently only CEOs with hire authority receive `hire_agent` and - * `file_approval`; any other agent's `internalTools` is empty. The - * decision is made at dispatch time from the resolved CEO mode so the - * worker never re-derives it. + * Hiring authority receives `hire_agent`; CEO strategy approval receives + * `file_approval` after the initial team-building hire pass has started. + * The decision is made at dispatch time so the worker never re-derives it. */ internalTools: ReadonlyArray<"hire_agent" | "file_approval">; } diff --git a/src/main/prompt-builder/ceo-modes/awaiting-hire.en.md b/src/main/prompt-builder/ceo-modes/awaiting-hire.en.md index d98f4452..74e9c6ea 100644 --- a/src/main/prompt-builder/ceo-modes/awaiting-hire.en.md +++ b/src/main/prompt-builder/ceo-modes/awaiting-hire.en.md @@ -3,5 +3,5 @@ Hires are filed. The Board is reviewing them. Your next turn is short and has three legitimate actions — do them in order and stop. 1. **Check approval status.** `GET /api/companies/{{companyId}}/approvals?state=pending` and `?state=revision_requested`. If any hire is `revision_requested`, read the decision note, address it in the payload you plan to resubmit, then POST `/api/approvals/{approvalId}/resubmit`. Do not file a duplicate hire in parallel. -2. **File the strategy (once).** If every intended hire is submitted and you have not yet posted an `approve_ceo_strategy` approval, post exactly one now summarising who reports to whom and which goal each lead owns. The API enforces hires-first and single-submission; see Step 4 of the team-building instructions for the exact shape. +2. **File the strategy (once).** If every intended hire is submitted and you have not yet filed an `approve_ceo_strategy` approval, call `file_approval` once with `type = "approve_ceo_strategy"` summarising who reports to whom and which goal each lead owns. The tool enforces hires-first and single-submission with `file_approval.strategy.before_hires` and `file_approval.strategy.already_filed`. 3. **Return.** Do **not** create tasks, projects, or documents before the first hire activates — the team cannot execute them yet. Status comments on your own tasks are also not productive here; the next heartbeat will flip mode automatically once hires activate. diff --git a/src/main/prompt-builder/ceo-modes/team-building.en.md b/src/main/prompt-builder/ceo-modes/team-building.en.md index b0074dd2..4f62da14 100644 --- a/src/main/prompt-builder/ceo-modes/team-building.en.md +++ b/src/main/prompt-builder/ceo-modes/team-building.en.md @@ -1,6 +1,6 @@ # Operating Instructions (CEO — Team Building Phase) -You are the new CEO. Your company has zero active team members. **Your one job this wake: file 3–5 `hire_agent` tool calls, then file one `file_approval` of type `approve_ceo_strategy`. Return.** Nothing else matters until a team exists — no tasks, no projects, no documents, no goal creation, no workspace analysis beyond the 30-second read below. +You are the new CEO. Your company has zero active team members. **Your one job this wake: file 3–5 `hire_agent` tool calls. Return.** Nothing else matters until a team exists — no tasks, no projects, no documents, no goal creation, no workspace analysis beyond the 30-second read below. ## Do this first — file the hires @@ -24,24 +24,9 @@ For each hire, make **one** `hire_agent` tool call. Do **not** file duplicates; Pick `connectorId` + `model` only from the **Available Connectors** block — anything else will be rejected by the tool with a validation error. Default domain skills are auto-assigned to each hire the moment approval lands; don't override unless you need to. -## Then file the strategy +## After the hires — stop -After every intended hire is filed — and only then — file exactly one `file_approval` with `type = "approve_ceo_strategy"` summarising the org: - -`file_approval` arguments: - -| field | required | notes | -|---|---|---| -| `type` | yes | Use `approve_ceo_strategy`. | -| `payloadSummary` | yes | ≤280 chars. Who reports to whom, which goal each lead owns. | -| `impactSummary` | yes | ≤400 chars. Why this shape. | -| `payloadJson` | optional | Structured payload JSON string. | - -The tool enforces the ordering gate. It returns `file_approval.strategy.before_hires` if no hires exist and `file_approval.strategy.already_filed` if you try to submit twice. Don't fight the gate. - -## After the hires + strategy — stop - -Do not create tasks, projects, meetings, knowledge entries, or documents in this wake. The mode flips to `awaiting_hire` or `operating` on the next heartbeat; do that work there. +The next heartbeat moves into hire approval follow-up or operating mode based on board decisions. Create tasks, projects, meetings, knowledge entries, and documents in those later modes. The filesystem analysis (ls, cat README, skim config) is optional — only run it if the mission is ambiguous from the active goals. A 20-second read max; do not write plan documents here. @@ -56,5 +41,5 @@ Moving a task you own to `in_review` without a reviewer returns `422 NO_REVIEWER ## End state for this wake - 3–5 `hire_agent` approvals in the approvals list -- Exactly one `approve_ceo_strategy` approval filed (pending or approved) -- Zero unnecessary goal-level tasks, plan documents, or analysis artefacts written this wake +- The CEO remains ready for the next mode transition +- No unnecessary goal-level tasks, plan documents, or analysis artefacts written this wake diff --git a/src/main/worker/runtime/helpers.ts b/src/main/worker/runtime/helpers.ts index 773add88..3d157962 100644 --- a/src/main/worker/runtime/helpers.ts +++ b/src/main/worker/runtime/helpers.ts @@ -24,9 +24,8 @@ export interface RunJob { /** * Internal tools (`hire_agent`, `file_approval`) to register on this * run's agent in addition to the default coding tools. Determined on - * main side before enqueue (see `pi-agent-factory.ts:inject hire_agent - * and file_approval into CEO agent`) so the worker never needs to - * inspect company/agent state. Empty array = no extra tools. + * main side before enqueue so the worker never needs to inspect + * company/agent state. Empty array = no extra tools. */ internalTools: ReadonlyArray<"hire_agent" | "file_approval">; } diff --git a/src/main/worker/runtime/internal-tools/hire-agent-tool.ts b/src/main/worker/runtime/internal-tools/hire-agent-tool.ts index db4bfbef..cd9984be 100644 --- a/src/main/worker/runtime/internal-tools/hire-agent-tool.ts +++ b/src/main/worker/runtime/internal-tools/hire-agent-tool.ts @@ -122,7 +122,7 @@ export function createHireAgentTool(rpc: WorkerRpcClient, runId: string): AgentT name: HIRE_AGENT_TOOL_NAME, label: "hire agent", description: - "File a hire_agent approval to add a new direct report to the company. Use when the CEO (or another manager) needs to fill a role. The approval is auto-approved for companies configured in hands-off mode; otherwise it goes to the board. Do not invoke for an agent that already exists.", + "File a hire_agent approval to add one direct report. During CEO Team Building, call this tool 3 to 5 times to populate the initial team. Outside Team Building, use it when the CEO or another manager needs to fill one role. The approval is auto-approved for hands-off companies; otherwise it goes to the board.", parameters: hireAgentSchema, async execute(toolCallId, params, _signal?, _onUpdate?) { const payload: InvokeInternalToolRpcPayload = { diff --git a/tests/integration/autonomous-chain/ceo-phased-prompts.spec.ts b/tests/integration/autonomous-chain/ceo-phased-prompts.spec.ts index 2ecc0be6..c721c0ac 100644 --- a/tests/integration/autonomous-chain/ceo-phased-prompts.spec.ts +++ b/tests/integration/autonomous-chain/ceo-phased-prompts.spec.ts @@ -24,14 +24,10 @@ describe("Step 2: CEO phased prompts", () => { const prompt = buildSystemPrompt(ctx); expect(prompt).toContain("Team Building Phase"); - // The rewritten prompt is action-first: it names the single job, - // shows the hire POST curl up front, and reaches for the - // server-enforced STRATEGY_BEFORE_HIRES / STRATEGY_ALREADY_FILED - // codes instead of prose ordering directives. expect(prompt).toContain("file 3–5 `hire_agent` tool calls"); expect(prompt).toContain("Do this first — file the hires"); - expect(prompt).toContain("file_approval.strategy.before_hires"); - expect(prompt).toContain("file_approval.strategy.already_filed"); + expect(prompt).not.toContain("file_approval.strategy.before_hires"); + expect(prompt).not.toContain("file_approval.strategy.already_filed"); expect(prompt).not.toContain("Awaiting Org Strategy Approval"); expect(prompt).not.toContain("Goal Decomposition"); }); @@ -51,6 +47,8 @@ describe("Step 2: CEO phased prompts", () => { const prompt = buildSystemPrompt(ctx); expect(prompt).toContain("Awaiting Hire Approvals"); + expect(prompt).toContain("call `file_approval` once"); + expect(prompt).toContain("file_approval.strategy.before_hires"); expect(prompt).not.toContain("Team Building Phase"); expect(prompt).not.toContain("Goal Decomposition"); }); diff --git a/tests/unit/internal-tool-selector.test.ts b/tests/unit/internal-tool-selector.test.ts new file mode 100644 index 00000000..3ced90da --- /dev/null +++ b/tests/unit/internal-tool-selector.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it, vi } from "vitest"; +import type { AppDatabase } from "@main/database"; +import { resolveInternalToolsForAgent } from "@main/orchestration/internal-tool-selector"; +import type { AgentRecord } from "@shared/types"; + +function makeAgent(overrides: Partial): AgentRecord { + return { + id: "agent-1", + companyId: "company-1", + name: "Agent", + role: "Engineer", + title: "Engineer", + roleLevel: "ic", + department: "engineering", + status: "idle", + reportsTo: "ceo-1", + connectorId: "openai-codex", + workspaceId: null, + model: null, + capabilities: "", + budgetMonthlyUsd: 100, + spentMonthlyUsd: 0, + metadataJson: "{}", + heartbeatEnabled: true, + heartbeatIntervalSec: 120, + lastHeartbeatAt: null, + createdAt: "2026-04-22T00:00:00.000Z", + updatedAt: "2026-04-22T00:00:00.000Z", + ...overrides, + }; +} + +function makeDb( + agent: AgentRecord, + reports: AgentRecord[], + approvals: Record> = {}, +): AppDatabase { + return { + entityCrud: { + getAgent: vi.fn((agentId: string) => { + const found = [agent, ...reports].find((candidate) => candidate.id === agentId); + if (!found) throw new Error(`Agent not found: ${agentId}`); + return found; + }), + }, + agents: { + hasAgentPermission: vi.fn(() => false), + getDirectReports: vi.fn(() => reports), + getChainOfCommand: vi.fn(() => []), + }, + approvalsMod: { + listCompanyApprovalsByType: vi.fn((_companyId: string, type: string) => approvals[type] ?? []), + }, + } as unknown as AppDatabase; +} + +describe("resolveInternalToolsForAgent", () => { + it("exposes only hire_agent during initial CEO team building", () => { + const ceo = makeAgent({ + id: "ceo-1", + role: "CEO", + title: "CEO", + roleLevel: "c_suite", + department: "executive", + reportsTo: null, + }); + const db = makeDb(ceo, []); + + expect(resolveInternalToolsForAgent(db, ceo)).toEqual(["hire_agent"]); + }); + + it("exposes file_approval after a hire is in flight", () => { + const ceo = makeAgent({ + id: "ceo-1", + role: "CEO", + title: "CEO", + roleLevel: "c_suite", + department: "executive", + reportsTo: null, + }); + const pendingHire = makeAgent({ + id: "pending-cto", + role: "CTO", + title: "CTO", + reportsTo: ceo.id, + status: "pending_approval", + }); + const db = makeDb(ceo, [pendingHire], { + hire_agent: [{ id: "approval-1", state: "pending" }], + }); + + expect(resolveInternalToolsForAgent(db, ceo)).toEqual(["hire_agent", "file_approval"]); + }); + + it("exposes file_approval when the CEO is operating with active reports", () => { + const ceo = makeAgent({ + id: "ceo-1", + role: "CEO", + title: "CEO", + roleLevel: "c_suite", + department: "executive", + reportsTo: null, + }); + const cto = makeAgent({ + id: "cto-1", + role: "CTO", + title: "CTO", + reportsTo: ceo.id, + status: "idle", + }); + const db = makeDb(ceo, [cto]); + + expect(resolveInternalToolsForAgent(db, ceo)).toEqual(["hire_agent", "file_approval"]); + }); + + it("keeps CEO strategy approval scoped to CEO agents", () => { + const manager = makeAgent({ + id: "manager-1", + role: "VP Engineering", + title: "VP Engineering", + roleLevel: "vp", + reportsTo: "ceo-1", + }); + const ic = makeAgent({ + id: "ic-1", + role: "Backend Engineer", + title: "Backend Engineer", + reportsTo: manager.id, + status: "idle", + }); + const db = makeDb(manager, [ic]); + + expect(resolveInternalToolsForAgent(db, manager)).toEqual(["hire_agent"]); + }); +});