From 003490e16b52e36b90ea73fba4e2014386c9719e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Mon, 20 Apr 2026 14:27:19 -0400 Subject: [PATCH] Downgrade initial-user-turn CEO hardcode to semantic cue (C4 part 4/5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/main/orchestration/initial-user-turn.ts | 93 ++++++++++++++------- 1 file changed, 64 insertions(+), 29 deletions(-) diff --git a/src/main/orchestration/initial-user-turn.ts b/src/main/orchestration/initial-user-turn.ts index 9515697c..f88573ff 100644 --- a/src/main/orchestration/initial-user-turn.ts +++ b/src/main/orchestration/initial-user-turn.ts @@ -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[],