Files
oh-my-pi/packages/coding-agent/src/session/irc-bridge.ts
T
2026-08-16 02:49:32 +02:00

209 lines
7.1 KiB
TypeScript

import type { Agent } from "@oh-my-pi/pi-agent-core";
import { logger, prompt } from "@oh-my-pi/pi-utils";
import type { Settings } from "../config/settings";
import { IrcBus, type IrcMessage } from "../irc/bus";
import parentIrcSteerTemplate from "../prompts/steering/parent-irc.md" with { type: "text" };
import ircAutoReplyTemplate from "../prompts/system/irc-autoreply.md" with { type: "text" };
import ircIncomingTemplate from "../prompts/system/irc-incoming.md" with { type: "text" };
import { AgentRegistry } from "../registry/agent-registry";
import type { AgentSessionEvent } from "./agent-session-events";
import type { CustomMessage } from "./messages";
import type { SessionManager } from "./session-manager";
/** Capabilities the IRC bridge borrows from its owning session. */
export interface IrcBridgeHost {
agent: Agent;
sessionManager: SessionManager;
settings: Settings;
isDisposed(): boolean;
isStreaming(): boolean;
planModeEnabled(): boolean;
emitSessionEvent(event: AgentSessionEvent): Promise<void>;
wakeForIrc(records: CustomMessage[]): void;
runEphemeralTurn(args: { promptText: string }): Promise<{ replyText: string }>;
}
/** Owns incoming IRC queues, injection, and side-channel auto-replies. */
export class IrcBridge {
readonly #host: IrcBridgeHost;
#interrupts: CustomMessage[] = [];
#asides: CustomMessage[] = [];
constructor(host: IrcBridgeHost) {
this.#host = host;
}
/** Whether an incoming peer message can interrupt a wait. */
hasInterrupts(): boolean {
return this.#interrupts.length > 0;
}
/** Whether any undelivered IRC record remains queued. */
hasPending(): boolean {
return this.#interrupts.length > 0 || this.#asides.length > 0;
}
/** Takes every queued IRC record in interrupt-before-aside order. */
drainPending(): CustomMessage[] {
const records = [...this.#interrupts, ...this.#asides];
this.#interrupts = [];
this.#asides = [];
return records;
}
/** Queues records whose idle wake must wait for a session transition to finish. */
deferWake(records: CustomMessage[]): void {
this.#asides.push(...records);
}
/** Surfaces and consumes queued incoming records before automatic injection. */
drainInboxMessages(agentId: string, opts?: { from?: string; limit?: number }): IrcMessage[] {
const messages: IrcMessage[] = [];
const remainingInterrupts: CustomMessage[] = [];
const remainingAsides: CustomMessage[] = [];
const queues = [
{ records: this.#interrupts, remaining: remainingInterrupts },
{ records: this.#asides, remaining: remainingAsides },
];
for (const queue of queues) {
for (const record of queue.records) {
if (record.customType !== "irc:incoming") {
queue.remaining.push(record);
continue;
}
const details = record.details;
if (!details || typeof details !== "object") {
queue.remaining.push(record);
continue;
}
const id = Reflect.get(details, "id");
const from = Reflect.get(details, "from");
const body = Reflect.get(details, "message");
const replyTo = Reflect.get(details, "replyTo");
if (typeof id !== "string" || typeof from !== "string" || typeof body !== "string") {
queue.remaining.push(record);
continue;
}
if (opts?.from !== undefined && from !== opts.from) {
queue.remaining.push(record);
continue;
}
if (opts?.limit !== undefined && messages.length >= opts.limit) {
queue.remaining.push(record);
continue;
}
messages.push({
id,
from,
to: agentId,
body,
ts: record.timestamp,
...(typeof replyTo === "string" ? { replyTo } : {}),
});
}
}
this.#interrupts = remainingInterrupts;
this.#asides = remainingAsides;
return messages;
}
/** Delivers an IRC message into the recipient session without awaiting any wake turn. */
async deliver(msg: IrcMessage, opts?: { expectsReply?: boolean }): Promise<"injected" | "woken"> {
if (this.#host.isDisposed()) throw new Error("Recipient session is disposed.");
const streaming = this.#host.isStreaming();
const planModeIdle = !streaming && this.#host.planModeEnabled();
const autoReply =
(opts?.expectsReply ?? false) && ((streaming && !this.#host.settings.get("async.enabled")) || planModeIdle);
const record: CustomMessage = {
role: "custom",
customType: "irc:incoming",
content: prompt.render(ircIncomingTemplate, {
from: msg.from,
message: msg.body,
replyTo: msg.replyTo ?? "",
autoReplied: autoReply,
interrupting: streaming,
}),
display: true,
details: { id: msg.id, from: msg.from, message: msg.body, ...(msg.replyTo ? { replyTo: msg.replyTo } : {}) },
attribution: "agent",
timestamp: msg.ts,
};
void this.#host.emitSessionEvent({ type: "irc_message", message: record });
if (streaming) {
const recipientParentId = AgentRegistry.global().get(msg.to)?.parentId;
if (recipientParentId === msg.from) {
this.#host.agent.steer({
role: "user",
content: prompt.render(parentIrcSteerTemplate, { from: msg.from, message: msg.body }),
attribution: "agent",
timestamp: msg.ts,
steering: true,
});
} else {
this.#interrupts.push(record);
}
if (autoReply) void this.#runAutoReply(msg);
return "injected";
}
if (this.#host.planModeEnabled()) {
this.#host.agent.appendMessage(record);
this.#host.sessionManager.appendCustomMessageEntry(
record.customType,
record.content,
record.display,
record.details,
record.attribution ?? "agent",
);
if (autoReply) void this.#runAutoReply(msg);
return "injected";
}
this.#host.wakeForIrc([record]);
return "woken";
}
/** Emits an IRC relay observation for rendering without persisting it. */
emitRelayObservation(record: CustomMessage): void {
void this.#host.emitSessionEvent({ type: "irc_message", message: record });
}
/** Persists queued IRC records that missed their step-boundary injection. */
flushPending(): void {
for (const record of this.drainPending()) {
this.#host.agent.emitExternalEvent({ type: "message_start", message: record });
this.#host.agent.emitExternalEvent({ type: "message_end", message: record });
}
}
async #runAutoReply(msg: IrcMessage): Promise<void> {
try {
const { replyText } = await this.#host.runEphemeralTurn({
promptText: prompt.render(ircAutoReplyTemplate, {
from: msg.from,
message: msg.body,
replyTo: msg.replyTo ?? "",
}),
});
const body = replyText.trim();
if (!body || this.#host.isDisposed()) return;
const record: CustomMessage = {
role: "custom",
customType: "irc:autoreply",
content: `[IRC you → \`${msg.from}\` (auto)]\n\n${body}`,
display: true,
details: { to: msg.from, body, replyTo: msg.id },
attribution: "agent",
timestamp: Date.now(),
};
void this.#host.emitSessionEvent({ type: "irc_message", message: record });
this.#asides.push(record);
const receipt = await IrcBus.global().send({ from: msg.to, to: msg.from, body, replyTo: msg.id });
if (receipt.outcome === "failed") {
logger.warn("IRC auto-reply delivery failed", { to: msg.from, error: receipt.error });
}
} catch (error) {
logger.warn("IRC auto-reply turn failed", { from: msg.from, error: String(error) });
}
}
}