From 271c6cfec8b9b5b447e80658815417823946cc30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sun, 19 Apr 2026 14:43:57 -0400 Subject: [PATCH] Bind communication wakes to concrete inbox rows Replace the worker's generic `"Continue."` default with a dispatch-time `initialUserTurn` that is resolved per trigger. Message and peer-request wakes now enumerate the actual unread messages (id, sender, channel, subject, reply-to) so the agent's first user turn carries the entities it is expected to act on, instead of idling into a status summary. Drop the system-prompt `heartbeatFallbackSection`: its "respond with a brief status summary" line fought the wake-reason block above it and was the main reason communication wakes degraded into self-narration. The wake-reason section plus the bound initial user turn now cover every case that section was carrying. --- src/main/orchestration/initial-user-turn.ts | 125 ++++++++++++++++++ src/main/orchestration/turn-context.ts | 9 +- src/main/orchestration/types.ts | 10 +- src/main/prompt-builder/index.ts | 8 +- src/main/prompt-builder/messages/system-en.ts | 2 - src/main/worker/worker-helpers.ts | 4 +- src/main/worker/worker-runner.ts | 8 +- 7 files changed, 152 insertions(+), 14 deletions(-) create mode 100644 src/main/orchestration/initial-user-turn.ts diff --git a/src/main/orchestration/initial-user-turn.ts b/src/main/orchestration/initial-user-turn.ts new file mode 100644 index 00000000..1e81445b --- /dev/null +++ b/src/main/orchestration/initial-user-turn.ts @@ -0,0 +1,125 @@ +/** + * Resolve the initial user-role turn that seeds an agent's pi run. + * + * The previous layout defaulted to the literal string `"Continue."` when + * no task was attached, so every message-wake or peer-request run started + * the agent with a prompt that carried no signal at all — combined with + * the system-prompt heartbeat fallback that invited status summaries, + * the recipient would routinely "reply" by narrating its own queue + * instead of answering the message that woke it. + * + * This module replaces that default with a trigger-specific imperative, + * and — for communication wakes — binds the initial turn to the actual + * inbox rows so the agent sees concrete `message_id` / sender references + * in its first user turn. The full message bodies still arrive as + * priorMessages through `prepareRun`'s inbox projection; this turn is + * the action cue on top of them. + */ + +import type { AppDatabase } from "../database"; +import type { AgentMessageRecord } from "@shared/types"; +import { HUMAN_SENDER_ID } from "../message-dispatch"; + +export interface InitialUserTurnDeps { + db: AppDatabase; +} + +const MAX_MESSAGE_LINES = 10; +const SUBJECT_PREVIEW_LEN = 80; + +export function resolveInitialUserTurn( + params: { + trigger: string | null; + task: ReturnType | null; + companyId: string; + agentId: string; + }, + deps: InitialUserTurnDeps, +): string { + const { trigger, task, companyId, agentId } = params; + + if (task) { + const description = (task.description ?? "").trim(); + return description.length > 0 ? `${task.title}\n\n${description}` : task.title; + } + + if (trigger === "message" || trigger === "peer_request") { + const unread = deps.db.listAgentInboxUnread(companyId, agentId); + if (unread.length > 0) { + return buildCommunicationTurn(trigger, unread, deps.db); + } + } + + return buildGenericWakeTurn(trigger); +} + +function buildCommunicationTurn( + trigger: "message" | "peer_request", + unread: AgentMessageRecord[], + db: AppDatabase, +): string { + const rows = unread.slice(0, MAX_MESSAGE_LINES).map((record) => { + const senderLabel = record.fromAgentId === HUMAN_SENDER_ID + ? "Board/Human" + : resolveAgentLabel(db, record.fromAgentId); + const subject = truncate(record.subject || "(no subject)", SUBJECT_PREVIEW_LEN); + const parentRef = record.parentMessageId ? ` · reply_to=${record.parentMessageId}` : ""; + return `- message_id=${record.id} · from=${senderLabel} (${record.fromAgentId}) · channel=${record.channel}${parentRef} · "${subject}"`; + }); + const overflow = unread.length > MAX_MESSAGE_LINES + ? `\n…and ${unread.length - MAX_MESSAGE_LINES} more in your inbox.` + : ""; + const header = trigger === "peer_request" + ? "A peer has requested your attention. Respond to each of the following messages:" + : "You have new messages. Respond to each of the following:"; + return `${header}\n${rows.join("\n")}${overflow}\n\nFor every row, reply to the sender using its message_id (channel-appropriate) and mark the message as read before moving on.`; +} + +function buildGenericWakeTurn(trigger: string | null): string { + switch (trigger) { + case "timer": + return "Heartbeat check. Review your briefing, then take the highest-priority action: deliverable reviews first, unblocking reports second, next step on assigned work third."; + case "assignment": + return "A new task has been assigned to you. Find it in your briefing, check it out via POST /api/issues/{issueId}/checkout, then begin work."; + case "comment": + return "A new comment landed on one of your tasks. Read the recent comments in your briefing and respond or adjust your work as needed."; + case "approval_resolved": + return "An approval you requested has been decided. Act on the outcome surfaced in your briefing."; + case "subtask_completed": + return "A subtask you delegated is ready for review. Evaluate the work against its acceptance criteria and either accept it or request changes."; + case "goal_activated": + return "A goal you own has been activated. Decompose it into projects and tasks, then delegate the first batch of work."; + case "report_blocked": + return "One of your reports is blocked. Investigate the blocker, provide direction or credentials, and unblock them."; + case "report_failed": + return "A run by one of your reports has failed. Diagnose, decide on retry or reassignment, then act."; + case "report_stuck": + return "One of your reports has exhausted its self-wake budget without progress. Check its state and take corrective action."; + case "task_continuation": + return "Continue on your most actionable assigned task."; + case "bootstrap": + return "This is an early heartbeat for your company. Translate active goals into concrete projects, tasks, and hiring plans."; + case "manual": + return "You were woken manually by the human operator. Review your briefing and act on whatever they are waiting on."; + case "message": + case "peer_request": + return "A communication arrived but your inbox is empty now — another run may have delivered it. Review your recent transcript and continue any in-progress collaboration."; + default: + return "Review your briefing and take the next appropriate action."; + } +} + +function resolveAgentLabel(db: AppDatabase, agentId: string): string { + try { + const name = db.findAgentName(agentId); + if (name && name.length > 0) return name; + } catch { + // fall through + } + return agentId; +} + +function truncate(text: string, max: number): string { + if (text.length <= max) return text; + return `${text.slice(0, max - 1)}…`; +} diff --git a/src/main/orchestration/turn-context.ts b/src/main/orchestration/turn-context.ts index 46e613dd..c5ad0747 100644 --- a/src/main/orchestration/turn-context.ts +++ b/src/main/orchestration/turn-context.ts @@ -13,6 +13,7 @@ import type { ConnectorId, ExecutionMode } from "@shared/types"; import type { WorkspaceLockMode, EnqueueRunPayload } from "./types"; import { normalizeCommentsForPrompt } from "../message-normalize"; import { getConnectorModelPolicy, validateConnectorModelSelection } from "../connector-model-policy"; +import { resolveInitialUserTurn } from "./initial-user-turn"; export interface RunDispatchParams { runId: string; @@ -332,12 +333,15 @@ export function buildRunPayload(params: RunDispatchParams, deps: TurnContextDeps throw new Error(modelSelection.error); } + const initialUserTurn = resolveInitialUserTurn( + { trigger, task, companyId, agentId: agent.id }, + { db }, + ); + return { runId, companyId, taskId: task?.id ?? null, - taskTitle: task?.title ?? "", - taskDescription: task?.description ?? "", agentId: agent.id, agentName: agent.name, connectorId: connector.id, @@ -351,5 +355,6 @@ export function buildRunPayload(params: RunDispatchParams, deps: TurnContextDeps trigger, taskSessionId: taskSessionId ?? runId, executionMode: db.getCompanyExecutionMode(companyId) as "ai_ceo" | "ai_cofounder", + initialUserTurn, }; } diff --git a/src/main/orchestration/types.ts b/src/main/orchestration/types.ts index 3e2a63c9..f999c2fd 100644 --- a/src/main/orchestration/types.ts +++ b/src/main/orchestration/types.ts @@ -23,8 +23,6 @@ export interface EnqueueRunPayload { companyId: string; /** Null for heartbeat runs (agent wake-ups with no assigned task). */ taskId: string | null; - taskTitle: string; - taskDescription: string; agentId: string; agentName: string; connectorId: string; @@ -40,6 +38,14 @@ export interface EnqueueRunPayload { taskSessionId: string; /** Execution mode from the owning company. */ executionMode: "ai_ceo" | "ai_cofounder"; + /** + * The literal user-role turn the worker seeds onto the pi Agent to + * kick off the loop. Resolved at dispatch time — for task runs this is + * the task title/description; for communication wakes it binds to the + * actual inbox rows (message_id, sender, subject) so the agent lands + * on a concrete action cue instead of a context-free `Continue.`. + */ + initialUserTurn: string; } export interface OrchestrationDependencies { diff --git a/src/main/prompt-builder/index.ts b/src/main/prompt-builder/index.ts index 62039aa2..14aa1bdb 100644 --- a/src/main/prompt-builder/index.ts +++ b/src/main/prompt-builder/index.ts @@ -69,10 +69,14 @@ export function buildTaskPrompt(ctx: PromptContext): string { parts.push(`\n${m.timeAwarenessSection}`); + // Taskless runs used to emit a "respond with a brief status summary" + // nudge here. That text fought the wake-reason block above and caused + // communication wakes to degrade into self-narration. The wake-reason + // section already tells the agent what to do; the initial user turn + // (assembled at dispatch) supplies the concrete action cue — no fallback + // text is needed for the system prompt anymore. if (ctx.task) { parts.push(m.immediateTaskSection(ctx.task.title, ctx.task.description, ctx.task.priority)); - } else { - parts.push(m.heartbeatFallbackSection); } return parts.join(""); diff --git a/src/main/prompt-builder/messages/system-en.ts b/src/main/prompt-builder/messages/system-en.ts index 5b5bc87f..286df29f 100644 --- a/src/main/prompt-builder/messages/system-en.ts +++ b/src/main/prompt-builder/messages/system-en.ts @@ -284,8 +284,6 @@ export function immediateTaskSection(title: string, description: string, priorit return `\n---\n\nYour immediate task:\n\nTitle: ${title}\nDescription: ${description || "No additional description."}\nPriority: ${priority}\n\nPlease execute this task according to your operating instructions.`; } -export const heartbeatFallbackSection = `\n---\n\nYou have been woken for a heartbeat check. Review your assigned tasks and active goals, then take appropriate action. If there's nothing to do, respond with a brief status summary.`; - export const dayNames: Record = { 0: "Sunday", 1: "Monday", 2: "Tuesday", 3: "Wednesday", 4: "Thursday", 5: "Friday", 6: "Saturday", diff --git a/src/main/worker/worker-helpers.ts b/src/main/worker/worker-helpers.ts index c8d1169d..ae0fae43 100644 --- a/src/main/worker/worker-helpers.ts +++ b/src/main/worker/worker-helpers.ts @@ -6,8 +6,6 @@ export interface RunJob { companyId: string; /** Null for heartbeat runs with no assigned task. Must stay aligned with EnqueueRunPayload. */ taskId: string | null; - taskTitle: string; - taskDescription: string; agentId: string; agentName: string; connectorId: string; @@ -21,6 +19,8 @@ export interface RunJob { trigger: string | null; taskSessionId: string; executionMode: "ai_ceo" | "ai_cofounder"; + /** Seed user turn that kicks off the pi agent loop. Never empty. */ + initialUserTurn: string; } /** diff --git a/src/main/worker/worker-runner.ts b/src/main/worker/worker-runner.ts index e2c3aead..082048ae 100644 --- a/src/main/worker/worker-runner.ts +++ b/src/main/worker/worker-runner.ts @@ -182,10 +182,10 @@ export class WorkerRunner { }); try { - const userMessage = job.taskTitle - ? `${job.taskTitle}\n\n${job.taskDescription ?? ""}`.trim() - : (job.taskDescription ?? ""); - const promptContent = userMessage.length > 0 ? userMessage : "Continue."; + const promptContent = job.initialUserTurn.trim(); + if (promptContent.length === 0) { + throw new Error(`Run ${job.runId} has an empty initial user turn.`); + } const promptMessage: AgentMessage = { role: "user", content: [{ type: "text", text: promptContent }],