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

+14 -1
View File
@@ -1,7 +1,7 @@
import log from "electron-log/main.js";
import type { AgentApiServer } from "../api-server";
import type { AppDatabase } from "../database";
import type { DesktopEvent, SectionId } from "@shared/types";
import type { AgentMessageRecord, DesktopEvent, SectionId } from "@shared/types";
import type { NotificationOptions } from "../notification-service";
import type { createServiceGraph } from "../service-graph";
import type { createThreadIntegration } from "../thread-integration";
@@ -24,6 +24,7 @@ export interface CreateApiServerFn {
onDocumentCreated: (companyId: string, authorAgentId: string | null, projectId: string | null, documentId: string, documentTitle: string, documentType: string, taskId: string | null) => void;
onAutoAssignTask: (taskId: string, companyId: string) => string | null;
onHumanMessage: (companyId: string, toAgentId?: string | null, threadId?: string | null, waitStateId?: string | null) => void;
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;
}, allowedOrigins: string[]): AgentApiServer;
@@ -108,6 +109,18 @@ export function wireApiServer(deps: WireApiServerDeps): AgentApiServer {
deps.threadIntegration.onHumanMessage(companyId, toAgentId, threadId, waitStateId);
deps.publishDomainChanged();
},
injectRuntimeMessage: (recipientAgentId, companyId, message) => {
const injector = deps.serviceGraph.getRuntimeMessageInjector();
if (!injector) return false;
try {
return injector.tryInject(recipientAgentId, companyId, message);
} catch (error) {
log.warn(
`[wire-api-server] runtime message injection failed for agent ${recipientAgentId}: ${error instanceof Error ? error.message : String(error)}`,
);
return false;
}
},
emitEvent: deps.emitEvent,
notify: deps.notify,
}, allowedOrigins);