Downgrade initial-user-turn CEO hardcode to semantic cue (C4 part 4/5)

Replace the 4-line `bash + node -e + fetch POST /api/companies/:id/
agents` + `POST /api/companies/:id/approvals` imperative with two
sentences that point at the Team Building Phase SOP and the new
`hire_agent` / `file_approval` tools (registered on the CEO in part
3/5). The cue is now `"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."`

All five pre-C4 couplings removed from this turn:
- Tool name (`bash`) — gone; cue references the tool surface generically
- Implementation detail (`node -e '...'` + `fetch`) — gone
- API paths (`/api/companies/:id/agents` + `/approvals`) — gone
- Business numbers (3-5 hires + 1 strategy) stay, moved into the cue
- Imperative tone (`NOW / Do not`) — dropped in favour of "execute now"

The fail-through path on the CEO lookup used to be `} catch {}`
(silent swallow). Migrated to `logAppError` under a recoverable
classification so a stale agent/company read still logs a structured
line instead of vanishing. The fallback behaviour (generic timer turn)
is unchanged — pi agent construction must not crash on a prompt-
assembly blip.

This commit is the first one to change model-observable behaviour.
The ceo-bootstrap-empty eval fixture (recorded against the old cue)
is expected to drift to 4/5 until re-recorded; IC / manager / non-CEO
fixtures stay green because they do not hit this code path.

tests: pnpm typecheck + pnpm test:unit (529) + pnpm test:eval (8/8) green
This commit is contained in:
ZacharyZhang-NY committed 2026-04-20 14:27:19 -04:00
1 parent b839ce4f23
commit 003490e16b
1 file changed
+64 -29
+64 -29
View File
@@ -19,6 +19,8 @@
import type { AppDatabase } from "../database";
import type { AgentMessageRecord } from "@shared/types";
import { HUMAN_SENDER_ID } from "../messaging/dispatch";
import { logAppError } from "../errors/index.js";
import log from "electron-log/main.js";
export interface InitialUserTurnDeps {
db: AppDatabase;
@@ -50,41 +52,74 @@ export function resolveInitialUserTurn(
}
}
// The generic timer turn hard-coded "deliverable reviews first,
// unblocking reports second, next step on assigned work third" —
// a ranking that presumes a manager or IC with tasks. For a new CEO
// in team_building mode, none of those clauses apply, so the model
// read "nothing to do" and returned without acting on the actual
// Operating Instructions that explicitly told it to file hires.
// Shortcut: when a CEO has zero active direct reports, replace the
// generic timer cue with a team-building cue that matches the CEO
// mode prompt.
// 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.
if (trigger === "timer" || trigger === "bootstrap" || trigger === "app_restart_resume") {
try {
const agent = deps.db.entityCrud.getAgent(agentId);
if (!agent.reportsTo) {
const activeReports = deps.db.agents
.getDirectReports(agentId, companyId)
.filter((r) => r.status !== "pending_approval" && r.status !== "terminated");
if (activeReports.length === 0) {
return [
"You are the CEO of this company. Your team is empty (zero active direct reports).",
"This wake has ONE job: call the `bash` tool to execute `node -e '...'` fetch POSTs that file 3–5 hire_agent requests against /api/companies/{companyId}/agents, then one approve_ceo_strategy POST against /api/companies/{companyId}/approvals.",
"The exact body shape and connectorId rules are in your Team Building Phase operating instructions above. Use a connectorId and model from the Available Connectors section (only ready connectors).",
"Start the first POST NOW. Do not write a plan document. Do not describe what you will do — execute the tool call.",
].join("\n\n");
}
}
} catch {
// Fall through to the generic turn — a lookup failure here must
// not crash the run; the system prompt still carries the full
// operating directives.
}
const cue = tryResolveCeoTeamBuildingCue({ agentId, companyId, db: deps.db });
if (cue !== null) return cue;
}
return buildGenericWakeTurn(trigger);
}
/**
* Returns the team-building cue when (and only when) the agent is a
* CEO (`reportsTo == null`) with zero active direct reports. Any other
* shape returns null and the caller falls through to
* `buildGenericWakeTurn`. A DB lookup failure is classified and
* logged — the caller never sees the raw error because the pi agent
* loop must not crash on a prompt-assembly hiccup, but the classified
* log line keeps the fail mode visible rather than swallowed.
*/
function tryResolveCeoTeamBuildingCue(params: {
agentId: string;
companyId: string;
db: AppDatabase;
}): string | null {
const { agentId, companyId, db } = params;
try {
const agent = db.entityCrud.getAgent(agentId);
if (agent.reportsTo) return null;
const activeReports = db.agents
.getDirectReports(agentId, companyId)
.filter((r) => r.status !== "pending_approval" && r.status !== "terminated");
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.",
].join("\n\n");
} catch (error) {
logAppError(
log,
error,
{
kind: "recoverable",
code: "initial_user_turn.ceo_lookup.failed",
message: `CEO team-building cue lookup failed for agent ${agentId}; falling back to the generic timer turn.`,
context: { agentId, companyId },
},
"warn",
);
return null;
}
}
function buildCommunicationTurn(
trigger: "message" | "peer_request",
unread: AgentMessageRecord[],