182 lines
7.9 KiB
TypeScript
182 lines
7.9 KiB
TypeScript
/**
|
|
* 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 "../messaging/dispatch";
|
|
import { logAppError } from "../errors/index.js";
|
|
import log from "electron-log/main.js";
|
|
|
|
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["entityCrud"]["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.messages.listAgentInboxUnread(companyId, agentId);
|
|
if (unread.length > 0) {
|
|
return buildCommunicationTurn(trigger, unread, deps.db);
|
|
}
|
|
}
|
|
|
|
// CEO / team-building semantic cue (C4 part 4/5).
|
|
//
|
|
// The turn names the state and points at the mode contract. The action
|
|
// policy lives in the tool schema and the CEO-mode instructions so model
|
|
// swaps do not depend on an imperative one-off user sentence.
|
|
if (trigger === "timer" || trigger === "bootstrap" || trigger === "app_restart_resume") {
|
|
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.",
|
|
"Follow the Team Building operating instructions using the tools exposed for this run, then return.",
|
|
].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[],
|
|
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. Read your briefing and execute the top-priority action described in your Operating Instructions for the mode you are currently in. If your briefing lists unread messages, reply to them before starting task work.";
|
|
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 "task_review_requested":
|
|
return "A direct report submitted a top-level task for your review. Validate against the original acceptance criteria before deciding.";
|
|
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.agents.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)}…`;
|
|
}
|