feat(messaging): route messages to live runs via pi steer

Incoming messages (human → agent, agent → agent, channel broadcast)
persisted to the inbox and were only projected into the recipient's
transcript on the next `prepareRun`. When the recipient was already
running, every message waited for the next wake — a Slack-like
system operating at heartbeat cadence, not in real time.

`RuntimeMessageInjector` closes the gap: it looks up the recipient's
active run through `RuntimeBridge.findActiveRunForAgent`, projects
the persisted `AgentMessageRecord` via the existing inbox-projection
helper, and posts a `steer-run` ParentMessage into the worker. The
worker's runner enqueues the projection on the Agent's steering
queue; pi drains it at the next turn boundary and emits the usual
`message_end` → `persistMessage` sequence, so the session store
shadow is preserved without double-writing.

`dispatchAgentMessage` gains an optional `injectRuntimeMessage` hook
used by every API-side dispatch. Injection succeeds → we mark the
inbox row delivered and skip `wakeAgentIfPossible`. Injection fails
(no active run) → the legacy inbox + wake path still fires, so
sleeping agents continue to receive their messages on the next turn.

The injector is owned by the service graph alongside `workerService`
and `piRuntimeRef`, which gives it both the worker postMessage
channel and the RuntimeBridge active-run map without additional
coupling. Internal dispatch call sites (sweepStaleReviews,
automation actions) leave the hook unset; those system messages
target idle agents by design and do not need the runtime path.
This commit is contained in:
ZacharyZhang-NY committed 2026-04-19 00:58:39 -04:00
1 parent 795008f56b
commit 5ee3d1394a
7 files changed
+151 -2

No files matched your search

+20
View File
@@ -64,6 +64,14 @@ export interface DispatchAgentMessageDependencies {
db: AppDatabase;
publishDomainChanged?: () => void;
wakeAgentIfPossible?: (agentId: string | null | undefined, companyId: string, trigger: string) => string | null;
/**
* Attempts to route a persisted message into the recipient's live pi
* run via `agent.steer()`. Returns true when delivery happened, false
* when the recipient has no active run. When true, dispatch skips
* `wakeAgentIfPossible` for that recipient because the message is
* already in-flight inside the agent loop.
*/
injectRuntimeMessage?: (recipientAgentId: string, companyId: string, message: AgentMessageRecord) => boolean;
emitEvent?: (event: DesktopEvent) => void;
notify?: (options: { title: string; body: string; urgency: "critical" | "informational"; navigation?: { section: SectionId; entityId?: string }; batchKey?: string }) => void;
/** Called when a human user sends a message. Resolves thread wait states scoped by waitStateId, threadId, or recipient. */
@@ -303,6 +311,18 @@ export function dispatchAgentMessage(
const agentNameMap = new Map(agentNameRows.map((a) => [a.id, a.name]));
for (const wakeTarget of wakeTargets) {
const agentName = agentNameMap.get(wakeTarget) ?? wakeTarget;
// Prefer live-loop injection: if the recipient is currently running
// a pi turn, the message lands in its steering queue and is
// observed within one turn. When no active run exists, fall back to
// the inbox + wake pipeline so a sleeping agent is woken with the
// message queued for its next `prepareRun` cycle.
const injected = message && deps.injectRuntimeMessage
? deps.injectRuntimeMessage(wakeTarget, normalized.companyId, message)
: false;
if (injected) {
mentionResults.push({ agentId: wakeTarget, agentName, outcome: "woken" });
continue;
}
const runId = deps.wakeAgentIfPossible(wakeTarget, normalized.companyId, wakeTrigger);
const hasQueuedWake = !runId && deps.db.listWakeupRequests(normalized.companyId, {
agentId: wakeTarget,