Agents always replied with channel="direct" regardless of the original message's channel, creating new DM conversations instead of replying in the existing group chat (department/company/project/incident). Root cause: three layers of the system guided agents toward direct-only replies: 1. Prompt instructions (wake-reasons, IC/manager operating instructions) hardcoded channel="direct" and toAgentId=fromAgentId for all replies 2. Standup API omitted toAgentId from unread messages, removing context about who the original message was addressed to 3. Inbox projection contained no reply-routing metadata, so the agent's LLM had no signal about which channel to respond in Fixes: - routes-agents-standup.ts: include toAgentId in unread message payload - wake-reasons/en.ts: differentiate direct vs group channel reply format - operating-en.ts: same channel-preserving guidance for IC and manager - inbox-projection.ts: embed reply-to routing context in message header so the LLM sees channel + target alongside the message body
112 lines
4.1 KiB
TypeScript
112 lines
4.1 KiB
TypeScript
/**
|
|
* 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 `messaging/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.
|
|
*
|
|
* `timestampOverride`, when supplied, replaces the wall-clock timestamp
|
|
* derived from `record.createdAt`. Callers use this to allocate a
|
|
* monotonically unique value per run — two inbound messages landing in
|
|
* the same millisecond would otherwise share the same key, and the
|
|
* `RuntimeBridge` correlation index would lose one author's identity
|
|
* on the `message_end` callback. The actual value still lives in pi's
|
|
* `timestamp` number so ordering semantics remain identical.
|
|
*/
|
|
export function projectInboxMessageToPi(
|
|
record: AgentMessageRecord,
|
|
directory: AgentDirectory,
|
|
options?: { timestampOverride?: number },
|
|
): AgentMessage {
|
|
const senderLabel = resolveSenderLabel(record, directory);
|
|
const channelLabel = formatChannelLabel(record);
|
|
const replyContext = record.channel === "direct"
|
|
? `reply-to: channel=direct, toAgentId=${record.fromAgentId}`
|
|
: `reply-to: channel=${record.channel}, channelTargetId=${record.channelTargetId ?? ""}`;
|
|
const header = channelLabel
|
|
? `[Message from ${senderLabel} · ${channelLabel} | ${replyContext}]`
|
|
: `[Message from ${senderLabel} | ${replyContext}]`;
|
|
|
|
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");
|
|
|
|
let timestamp = options?.timestampOverride;
|
|
if (typeof timestamp !== "number" || !Number.isFinite(timestamp)) {
|
|
const parsed = Date.parse(record.createdAt);
|
|
timestamp = Number.isFinite(parsed) ? parsed : Date.now();
|
|
}
|
|
return {
|
|
role: "user",
|
|
content: [{ type: "text", text }],
|
|
timestamp,
|
|
} as AgentMessage;
|
|
}
|