refactor(messaging): project inter-agent messages into pi session transcript

Inter-agent messages previously entered the recipient's context as a
separate prompt block (recentMessages), rebuilt every turn. This double-
charged tokens and kept messages outside pi's native AgentMessage stream.

Now at prepareRun: pull the recipient's unread inbox, project each
AgentMessageRecord into a pi user-role AgentMessage, persist to
SqliteSessionStore, and mark delivered. The recipient reads messages
as natural conversation turns in the transcript.

- db: listAgentInboxUnread + markAgentInboxDelivered (transactional)
- agent-runtime/inbox-projection.ts: AgentMessageRecord -> pi AgentMessage
- runtime-bridge.prepareRun: inject + deliver before loadMessages
- prompt-builder: delete recentMessages context block + buildMessagesSection
- message-normalize: drop unused normalizeMessagesForPrompt
- delete prompt-builder/attachment-resolver.ts (orphan after section removal)

Send path (api-server/routes-messages, ipc, UI) unchanged — inbox table
remains the UI source. Transactional delivery marking prevents duplicate
injection across runs. Failed append -> no mark -> retry on next run.
This commit is contained in:
ZacharyZhang-NY committed 2026-04-17 14:48:22 -04:00
1 parent 5547c29491
commit b50716d4e8
11 files changed
+254 -175

No files matched your search

@@ -0,0 +1,96 @@
/**
* Inbox projection — converts a stored AgentMessageRecord into a pi
* AgentMessage (user role) so inter-agent communication enters the
* recipient's transcript as natural conversation rather than a prompt
* block re-rendered every turn.
*
* The projection is the single source of truth for how an inbound message
* looks inside a recipient's context: `[Message from {sender}{· channel}]`
* header, optional subject line, then the body. Content is normalized
* (control characters stripped, oversized bodies truncated) via the same
* helpers used by the legacy prompt-builder boundary.
*/
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { AgentMessageRecord } from "@shared/types";
const MAX_MESSAGE_BODY_LENGTH = 4000;
const MAX_SUBJECT_LENGTH = 200;
export interface AgentDirectory {
getAgentName(agentId: string): string;
}
/**
* Strip control characters (except newlines/tabs) and collapse excessive
* whitespace runs. Mirrors `message-normalize.ts#cleanText` to keep the
* transcript resilient against garbage from upstream senders.
*/
function cleanText(text: string): string {
// eslint-disable-next-line no-control-regex
return text.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "")
.replace(/[ \t]{4,}/g, " ")
.trim();
}
function truncate(text: string, maxLen: number): string {
if (text.length <= maxLen) return text;
return text.slice(0, maxLen - 1) + "…";
}
function formatChannelLabel(record: AgentMessageRecord): string | null {
if (record.channel === "direct") return null;
if (record.channelTargetId) return `${record.channel}:${record.channelTargetId}`;
return record.channel;
}
function resolveSenderLabel(
record: AgentMessageRecord,
directory: AgentDirectory,
): string {
switch (record.senderKind) {
case "human":
// The board-reader virtual account is the only non-agent human sender
// surfaced in practice; render it as "Board" so agents know where
// strategic directives originate.
if (record.fromAgentId === "__board__") return "Board";
return "User";
case "system":
return "System";
case "agent":
default:
return directory.getAgentName(record.fromAgentId);
}
}
/**
* Projects a stored `AgentMessageRecord` into a pi `AgentMessage`
* (role: "user") suitable for appending to the recipient's session
* transcript. `timestamp` carries the message's `createdAt` in
* ms-since-epoch so transcript ordering aligns with wall-clock time.
*/
export function projectInboxMessageToPi(
record: AgentMessageRecord,
directory: AgentDirectory,
): AgentMessage {
const senderLabel = resolveSenderLabel(record, directory);
const channelLabel = formatChannelLabel(record);
const header = channelLabel
? `[Message from ${senderLabel} · ${channelLabel}]`
: `[Message from ${senderLabel}]`;
const subject = record.subject ? truncate(cleanText(record.subject), MAX_SUBJECT_LENGTH) : "";
const body = truncate(cleanText(record.body), MAX_MESSAGE_BODY_LENGTH);
const parts: string[] = [header];
if (subject.length > 0) parts.push(subject);
if (body.length > 0) parts.push(body);
const text = parts.join("\n\n");
const timestamp = Date.parse(record.createdAt);
return {
role: "user",
content: [{ type: "text", text }],
timestamp: Number.isFinite(timestamp) ? timestamp : Date.now(),
} as AgentMessage;
}
+41 -1
View File
@@ -6,6 +6,7 @@ import { SqliteSessionStore } from "./sqlite-session-store";
import { CostWriter } from "./cost-writer";
import type { CredentialResolver } from "@main/credentials";
import { getConnectorDefinition } from "@main/connectors";
import { projectInboxMessageToPi } from "./inbox-projection";
/**
* Projects a pi AgentEvent to a human-readable log line for the Console.
@@ -168,6 +169,23 @@ export class RuntimeBridge {
return store;
}
/**
* Resolve a display name for an agent id used in inbox header lines.
* Falls back to the id when the lookup fails so the transcript remains
* unambiguous instead of silently rendering "undefined".
*/
private resolveAgentName(agentId: string): string {
try {
const name = this.deps.db.findAgentName(agentId);
if (name && name.length > 0) return name;
} catch (error) {
this.deps.logger.warn(
`[runtime-bridge] agent name lookup failed for ${agentId}: ${error instanceof Error ? error.message : String(error)}`,
);
}
return agentId;
}
/** Receives an `agent-event` envelope from the worker; emits to renderer. */
handleAgentEvent(envelope: {
runId: string;
@@ -303,7 +321,29 @@ export class RuntimeBridge {
});
const definition = getConnectorDefinition(payload.connectorId);
const resolved = await this.deps.credentialResolver.resolve(payload.connectorId);
const priorMessages = this.getOrCreateStore(payload.taskSessionId).loadMessages();
// Project any pending inbox messages into the session transcript
// *before* loading priorMessages — the recipient must observe them
// as plain user turns in its context, and the transcript row is
// the single source of memory (recentMessages prompt block is
// gone). Append fails closed: if persistence throws, the inbox
// row stays unread and we surface the error so the worker aborts.
const store = this.getOrCreateStore(payload.taskSessionId);
const inboxRecords = this.deps.db.listAgentInboxUnread(payload.companyId, payload.agentId);
for (const record of inboxRecords) {
const projected = projectInboxMessageToPi(record, {
getAgentName: (id) => this.resolveAgentName(id),
});
store.appendMessage(projected);
}
if (inboxRecords.length > 0) {
this.deps.db.markAgentInboxDelivered(
payload.agentId,
inboxRecords.map((r) => r.id),
);
}
const priorMessages = store.loadMessages();
return {
ok: true,
result: {