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:
1 parent
e917c614c9
commit
271c6cfec8
7 files changed
+152
-14
No files matched your search
@@ -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)}…`;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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("");
|
||||
|
||||
@@ -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<number, string> = {
|
||||
0: "Sunday", 1: "Monday", 2: "Tuesday", 3: "Wednesday",
|
||||
4: "Thursday", 5: "Friday", 6: "Saturday",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 }],
|
||||
|
||||
Reference in new issue
Block a user