fix(runtime): move team building policy into tools

This commit is contained in:
ZacharyZhang-NY committed 2026-04-22 16:38:59 -04:00
1 parent 473486aa61
commit 612a760152
9 files changed
+184 -62

No files matched your search

+4 -17
View File
@@ -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(
@@ -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
);
}
+3 -4
View File
@@ -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">;
}
@@ -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.
@@ -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
+2 -3
View File
@@ -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">;
}
@@ -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 = {
@@ -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");
});
+135
View File
@@ -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>): 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<string, Array<{ id: string; state: string }>> = {},
): 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"]);
});
});