Files
oh-my-pi/packages/coding-agent/src/tools/irc.ts
T
can1357 fe25549d1f refactor(coding-agent): updated hashline/grep anchors to * and |-separated output
- Updated `formatMatchLine` to emit `*` for matched lines, a leading space for context, and a `|` anchor/content separator.
- Revised grep/hashline mismatch messages and prompts to describe the new marker and separator format.
- Aligned affected atom and hashline tests with the updated match-line prefixes and separators.
2026-04-26 10:41:07 +02:00

259 lines
7.5 KiB
TypeScript

/**
* IRC tool — agent-to-agent messaging.
*
* Lets any live agent send a short prose message to any other live agent in
* this process and (optionally) get a prose reply.
*
* Routing happens via the global AgentRegistry. Replies are produced by an
* ephemeral side-channel call (`AgentSession.respondAsBackground`) that
* mirrors `/btw`: the recipient's current model, system prompt, and message
* history are used to compute a reply without persisting it through the
* normal stream path. After the reply is generated, both the incoming
* message and the auto-reply are queued for injection into the recipient's
* persisted history (deferred until the recipient is idle), so the model
* sees the exchange on its next turn.
*
* This avoids the deadlock that arises when the recipient is blocked on a
* long-running tool call: the side-channel call does not depend on the
* recipient's main agent loop being free.
*/
import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
import { prompt } from "@oh-my-pi/pi-utils";
import { type Static, Type } from "@sinclair/typebox";
import ircDescription from "../prompts/tools/irc.md" with { type: "text" };
import type { AgentRef, AgentRegistry } from "../registry/agent-registry";
import type { ToolSession } from ".";
const ircSchema = Type.Object({
op: Type.Union(
[
Type.Literal("send", { description: "Send a message to one peer or to all peers" }),
Type.Literal("list", { description: "List currently visible peers" }),
],
{ description: "IRC operation" },
),
to: Type.Optional(
Type.String({
description: 'Recipient agent id (e.g. "0-Main", "0-AuthLoader") or "all" to broadcast',
examples: ["0-Main", "all"],
}),
),
message: Type.Optional(
Type.String({
description: "Message body to deliver",
examples: ["Should we use JWT or session cookies?"],
}),
),
awaitReply: Type.Optional(
Type.Boolean({
description: "Wait for the recipient's prose reply (default: true for DM, false for broadcast)",
}),
),
});
type IrcParams = Static<typeof ircSchema>;
interface IrcReply {
from: string;
text: string;
}
export interface IrcDetails {
op: "send" | "list";
from?: string;
to?: string;
delivered?: string[];
replies?: IrcReply[];
failed?: Array<{ id: string; error: string }>;
notFound?: string[];
peers?: Array<{ id: string; displayName: string; kind: string; status: string; parentId?: string }>;
channels?: string[];
}
export class IrcTool implements AgentTool<typeof ircSchema, IrcDetails> {
readonly name = "irc";
readonly label = "IRC";
readonly description: string;
readonly parameters = ircSchema;
readonly strict = true;
constructor(private readonly session: ToolSession) {
this.description = prompt.render(ircDescription);
}
static createIf(session: ToolSession): IrcTool | null {
if (!session.settings.get("irc.enabled")) return null;
if (!session.agentRegistry || !session.getAgentId) return null;
return new IrcTool(session);
}
async execute(
_toolCallId: string,
params: IrcParams,
signal?: AbortSignal,
_onUpdate?: AgentToolUpdateCallback<IrcDetails>,
_context?: AgentToolContext,
): Promise<AgentToolResult<IrcDetails>> {
const registry = this.session.agentRegistry;
const senderId = this.session.getAgentId?.() ?? null;
if (!registry) {
return errorResult("IRC is unavailable in this session.", { op: params.op });
}
if (!senderId) {
return errorResult("IRC is unavailable: caller has no agent id.", { op: params.op });
}
if (params.op === "list") {
return this.#executeList(registry, senderId);
}
if (params.op === "send") {
return this.#executeSend(registry, senderId, params, signal);
}
return errorResult("Unknown irc op.", { op: params.op as "send" | "list" });
}
#executeList(registry: AgentRegistry, senderId: string): AgentToolResult<IrcDetails> {
const peers = registry.listVisibleTo(senderId);
const lines: string[] = [];
if (peers.length === 0) {
lines.push("No other live agents.");
} else {
lines.push(`${peers.length} peer(s):`);
for (const peer of peers) {
lines.push(`- ${peer.id} [${peer.displayName} · ${peer.kind} · ${peer.status}]`);
}
}
const channels = ["all", ...peers.map(p => p.id)];
return {
content: [{ type: "text", text: lines.join("\n") }],
details: {
op: "list",
from: senderId,
peers: peers.map(p => ({
id: p.id,
displayName: p.displayName,
kind: p.kind,
status: p.status,
parentId: p.parentId,
})),
channels,
},
};
}
async #executeSend(
registry: AgentRegistry,
senderId: string,
params: IrcParams,
signal?: AbortSignal,
): Promise<AgentToolResult<IrcDetails>> {
const to = params.to?.trim();
const message = params.message?.trim();
if (!to) {
return errorResult('`to` is required for op="send".', { op: "send", from: senderId });
}
if (!message) {
return errorResult('`message` is required for op="send".', { op: "send", from: senderId });
}
// Resolve target peers.
let targets: AgentRef[];
const notFound: string[] = [];
const isBroadcast = to === "all";
if (isBroadcast) {
targets = registry.listVisibleTo(senderId);
} else {
const ref = registry.get(to);
if (!ref || ref.id === senderId) {
notFound.push(to);
targets = [];
} else if (ref.status !== "running" && ref.status !== "idle") {
notFound.push(to);
targets = [];
} else {
targets = [ref];
}
}
const awaitReply = params.awaitReply ?? !isBroadcast;
const delivered: string[] = [];
const replies: IrcReply[] = [];
const failed: Array<{ id: string; error: string }> = [];
// Dispatch to each target in parallel via the recipient's ephemeral
// side-channel. Independent calls so a slow recipient cannot stall the
// others. The recipient's main loop never has to be unblocked: the
// side-channel runs alongside any in-flight tool call.
const dispatches = targets.map(async target => {
const targetSession = target.session;
if (!targetSession) {
notFound.push(target.id);
return;
}
try {
const result = await targetSession.respondAsBackground({
from: senderId,
message,
awaitReply,
signal,
});
delivered.push(target.id);
if (awaitReply && result.replyText) {
replies.push({ from: target.id, text: result.replyText });
}
} catch (err) {
failed.push({ id: target.id, error: err instanceof Error ? err.message : String(err) });
}
});
await Promise.all(dispatches);
const lines: string[] = [];
if (delivered.length === 0) {
lines.push("No recipients received the message.");
} else {
lines.push(`Delivered to ${delivered.length} peer(s): ${delivered.join(", ")}`);
}
if (replies.length > 0) {
lines.push("");
lines.push("## Replies");
for (const reply of replies) {
lines.push(`### ${reply.from}`);
lines.push(reply.text);
}
}
if (failed.length > 0) {
lines.push("");
lines.push("## Failed");
for (const f of failed) {
lines.push(`- ${f.id}: ${f.error}`);
}
}
if (notFound.length > 0) {
lines.push("");
lines.push(`Unknown / unavailable peers: ${notFound.join(", ")}`);
}
return {
content: [{ type: "text", text: lines.join("\n") }],
details: {
op: "send",
from: senderId,
to,
delivered,
...(replies.length > 0 ? { replies } : {}),
...(failed.length > 0 ? { failed } : {}),
...(notFound.length > 0 ? { notFound } : {}),
},
};
}
}
function errorResult(text: string, details: IrcDetails): AgentToolResult<IrcDetails> {
return {
content: [{ type: "text", text }],
details,
};
}