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.
This commit is contained in:
ZacharyZhang-NY committed 2026-04-19 14:43:57 -04:00
1 parent e917c614c9
commit 271c6cfec8
7 files changed
+152 -14

No files matched your search

+125
View File
@@ -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<AppDatabase["getTask"]> | 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)}…`;
}
+7 -2
View File
@@ -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,
};
}
+8 -2
View File
@@ -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 {