refactor(src): split oversized modules

This commit is contained in:
Peter Steinberger
2026-01-14 01:08:15 +00:00
parent b2179de839
commit bcbfb357be
675 changed files with 91476 additions and 73453 deletions

View File

@@ -0,0 +1,109 @@
import { Type } from "@sinclair/typebox";
import { optionalStringEnum, stringEnum } from "../schema/typebox.js";
const BROWSER_ACT_KINDS = [
"click",
"type",
"press",
"hover",
"drag",
"select",
"fill",
"resize",
"wait",
"evaluate",
"close",
] as const;
const BROWSER_TOOL_ACTIONS = [
"status",
"start",
"stop",
"tabs",
"open",
"focus",
"close",
"snapshot",
"screenshot",
"navigate",
"console",
"pdf",
"upload",
"dialog",
"act",
] as const;
const BROWSER_TARGETS = ["sandbox", "host", "custom"] as const;
const BROWSER_SNAPSHOT_FORMATS = ["aria", "ai"] as const;
const BROWSER_IMAGE_TYPES = ["png", "jpeg"] as const;
// NOTE: Using a flattened object schema instead of Type.Union([Type.Object(...), ...])
// because Claude API on Vertex AI rejects nested anyOf schemas as invalid JSON Schema.
// The discriminator (kind) determines which properties are relevant; runtime validates.
const BrowserActSchema = Type.Object({
kind: stringEnum(BROWSER_ACT_KINDS),
// Common fields
targetId: Type.Optional(Type.String()),
ref: Type.Optional(Type.String()),
// click
doubleClick: Type.Optional(Type.Boolean()),
button: Type.Optional(Type.String()),
modifiers: Type.Optional(Type.Array(Type.String())),
// type
text: Type.Optional(Type.String()),
submit: Type.Optional(Type.Boolean()),
slowly: Type.Optional(Type.Boolean()),
// press
key: Type.Optional(Type.String()),
// drag
startRef: Type.Optional(Type.String()),
endRef: Type.Optional(Type.String()),
// select
values: Type.Optional(Type.Array(Type.String())),
// fill - use permissive array of objects
fields: Type.Optional(
Type.Array(Type.Object({}, { additionalProperties: true })),
),
// resize
width: Type.Optional(Type.Number()),
height: Type.Optional(Type.Number()),
// wait
timeMs: Type.Optional(Type.Number()),
textGone: Type.Optional(Type.String()),
// evaluate
fn: Type.Optional(Type.String()),
});
// IMPORTANT: OpenAI function tool schemas must have a top-level `type: "object"`.
// A root-level `Type.Union([...])` compiles to `{ anyOf: [...] }` (no `type`),
// which OpenAI rejects ("Invalid schema ... type: None"). Keep this schema an object.
export const BrowserToolSchema = Type.Object({
action: stringEnum(BROWSER_TOOL_ACTIONS),
target: optionalStringEnum(BROWSER_TARGETS),
profile: Type.Optional(Type.String()),
controlUrl: Type.Optional(Type.String()),
targetUrl: Type.Optional(Type.String()),
targetId: Type.Optional(Type.String()),
limit: Type.Optional(Type.Number()),
maxChars: Type.Optional(Type.Number()),
format: optionalStringEnum(BROWSER_SNAPSHOT_FORMATS),
interactive: Type.Optional(Type.Boolean()),
compact: Type.Optional(Type.Boolean()),
depth: Type.Optional(Type.Number()),
selector: Type.Optional(Type.String()),
frame: Type.Optional(Type.String()),
fullPage: Type.Optional(Type.Boolean()),
ref: Type.Optional(Type.String()),
element: Type.Optional(Type.String()),
type: optionalStringEnum(BROWSER_IMAGE_TYPES),
level: Type.Optional(Type.String()),
paths: Type.Optional(Type.Array(Type.String())),
inputRef: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
accept: Type.Optional(Type.Boolean()),
promptText: Type.Optional(Type.String()),
request: Type.Optional(BrowserActSchema),
});

View File

@@ -1,5 +1,3 @@
import { Type } from "@sinclair/typebox";
import {
browserCloseTab,
browserFocusTab,
@@ -22,7 +20,7 @@ import {
import { resolveBrowserConfig } from "../../browser/config.js";
import { DEFAULT_AI_SNAPSHOT_MAX_CHARS } from "../../browser/constants.js";
import { loadConfig } from "../../config/config.js";
import { optionalStringEnum, stringEnum } from "../schema/typebox.js";
import { BrowserToolSchema } from "./browser-tool.schema.js";
import {
type AnyAgentTool,
imageResultFromFile,
@@ -30,112 +28,6 @@ import {
readStringParam,
} from "./common.js";
const BROWSER_ACT_KINDS = [
"click",
"type",
"press",
"hover",
"drag",
"select",
"fill",
"resize",
"wait",
"evaluate",
"close",
] as const;
const BROWSER_TOOL_ACTIONS = [
"status",
"start",
"stop",
"tabs",
"open",
"focus",
"close",
"snapshot",
"screenshot",
"navigate",
"console",
"pdf",
"upload",
"dialog",
"act",
] as const;
const BROWSER_TARGETS = ["sandbox", "host", "custom"] as const;
const BROWSER_SNAPSHOT_FORMATS = ["aria", "ai"] as const;
const BROWSER_IMAGE_TYPES = ["png", "jpeg"] as const;
// NOTE: Using a flattened object schema instead of Type.Union([Type.Object(...), ...])
// because Claude API on Vertex AI rejects nested anyOf schemas as invalid JSON Schema.
// The discriminator (kind) determines which properties are relevant; runtime validates.
const BrowserActSchema = Type.Object({
kind: stringEnum(BROWSER_ACT_KINDS),
// Common fields
targetId: Type.Optional(Type.String()),
ref: Type.Optional(Type.String()),
// click
doubleClick: Type.Optional(Type.Boolean()),
button: Type.Optional(Type.String()),
modifiers: Type.Optional(Type.Array(Type.String())),
// type
text: Type.Optional(Type.String()),
submit: Type.Optional(Type.Boolean()),
slowly: Type.Optional(Type.Boolean()),
// press
key: Type.Optional(Type.String()),
// drag
startRef: Type.Optional(Type.String()),
endRef: Type.Optional(Type.String()),
// select
values: Type.Optional(Type.Array(Type.String())),
// fill - use permissive array of objects
fields: Type.Optional(
Type.Array(Type.Object({}, { additionalProperties: true })),
),
// resize
width: Type.Optional(Type.Number()),
height: Type.Optional(Type.Number()),
// wait
timeMs: Type.Optional(Type.Number()),
textGone: Type.Optional(Type.String()),
// evaluate
fn: Type.Optional(Type.String()),
});
// IMPORTANT: OpenAI function tool schemas must have a top-level `type: "object"`.
// A root-level `Type.Union([...])` compiles to `{ anyOf: [...] }` (no `type`),
// which OpenAI rejects ("Invalid schema ... type: None"). Keep this schema an object.
const BrowserToolSchema = Type.Object({
action: stringEnum(BROWSER_TOOL_ACTIONS),
target: optionalStringEnum(BROWSER_TARGETS),
profile: Type.Optional(Type.String()),
controlUrl: Type.Optional(Type.String()),
targetUrl: Type.Optional(Type.String()),
targetId: Type.Optional(Type.String()),
limit: Type.Optional(Type.Number()),
maxChars: Type.Optional(Type.Number()),
format: optionalStringEnum(BROWSER_SNAPSHOT_FORMATS),
interactive: Type.Optional(Type.Boolean()),
compact: Type.Optional(Type.Boolean()),
depth: Type.Optional(Type.Number()),
selector: Type.Optional(Type.String()),
frame: Type.Optional(Type.String()),
fullPage: Type.Optional(Type.Boolean()),
ref: Type.Optional(Type.String()),
element: Type.Optional(Type.String()),
type: optionalStringEnum(BROWSER_IMAGE_TYPES),
level: Type.Optional(Type.String()),
paths: Type.Optional(Type.Array(Type.String())),
inputRef: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
accept: Type.Optional(Type.Boolean()),
promptText: Type.Optional(Type.String()),
request: Type.Optional(BrowserActSchema),
});
function resolveBrowserBaseUrl(params: {
target?: "sandbox" | "host" | "custom";
controlUrl?: string;

View File

@@ -0,0 +1,95 @@
import type { AssistantMessage } from "@mariozechner/pi-ai";
import type { ClawdbotConfig } from "../../config/config.js";
import { extractAssistantText } from "../pi-embedded-utils.js";
export type ImageModelConfig = { primary?: string; fallbacks?: string[] };
export function decodeDataUrl(dataUrl: string): {
buffer: Buffer;
mimeType: string;
kind: "image";
} {
const trimmed = dataUrl.trim();
const match = /^data:([^;,]+);base64,([a-z0-9+/=\r\n]+)$/i.exec(trimmed);
if (!match) throw new Error("Invalid data URL (expected base64 data: URL).");
const mimeType = (match[1] ?? "").trim().toLowerCase();
if (!mimeType.startsWith("image/")) {
throw new Error(`Unsupported data URL type: ${mimeType || "unknown"}`);
}
const b64 = (match[2] ?? "").trim();
const buffer = Buffer.from(b64, "base64");
if (buffer.length === 0) {
throw new Error("Invalid data URL: empty payload.");
}
return { buffer, mimeType, kind: "image" };
}
export function coerceImageAssistantText(params: {
message: AssistantMessage;
provider: string;
model: string;
}): string {
const stop = params.message.stopReason;
const errorMessage = params.message.errorMessage?.trim();
if (stop === "error" || stop === "aborted") {
throw new Error(
errorMessage
? `Image model failed (${params.provider}/${params.model}): ${errorMessage}`
: `Image model failed (${params.provider}/${params.model})`,
);
}
if (errorMessage) {
throw new Error(
`Image model failed (${params.provider}/${params.model}): ${errorMessage}`,
);
}
const text = extractAssistantText(params.message);
if (text.trim()) return text.trim();
throw new Error(
`Image model returned no text (${params.provider}/${params.model}).`,
);
}
export function coerceImageModelConfig(cfg?: ClawdbotConfig): ImageModelConfig {
const imageModel = cfg?.agents?.defaults?.imageModel as
| { primary?: string; fallbacks?: string[] }
| string
| undefined;
const primary =
typeof imageModel === "string" ? imageModel.trim() : imageModel?.primary;
const fallbacks =
typeof imageModel === "object" ? (imageModel?.fallbacks ?? []) : [];
return {
...(primary?.trim() ? { primary: primary.trim() } : {}),
...(fallbacks.length > 0 ? { fallbacks } : {}),
};
}
export function resolveProviderVisionModelFromConfig(params: {
cfg?: ClawdbotConfig;
provider: string;
}): string | null {
const providerCfg = params.cfg?.models?.providers?.[
params.provider
] as unknown as
| { models?: Array<{ id?: string; input?: string[] }> }
| undefined;
const models = providerCfg?.models ?? [];
const preferMinimaxVl =
params.provider === "minimax"
? models.find(
(m) =>
(m?.id ?? "").trim() === "MiniMax-VL-01" &&
Array.isArray(m?.input) &&
m.input.includes("image"),
)
: null;
const picked =
preferMinimaxVl ??
models.find(
(m) => Boolean((m?.id ?? "").trim()) && m.input?.includes("image"),
);
const id = (picked?.id ?? "").trim();
return id ? `${params.provider}/${id}` : null;
}

View File

@@ -27,108 +27,23 @@ import { getApiKeyForModel, resolveEnvApiKey } from "../model-auth.js";
import { runWithImageModelFallback } from "../model-fallback.js";
import { parseModelRef } from "../model-selection.js";
import { ensureClawdbotModelsJson } from "../models-config.js";
import { extractAssistantText } from "../pi-embedded-utils.js";
import { assertSandboxPath } from "../sandbox-paths.js";
import type { AnyAgentTool } from "./common.js";
import {
coerceImageAssistantText,
coerceImageModelConfig,
decodeDataUrl,
type ImageModelConfig,
resolveProviderVisionModelFromConfig,
} from "./image-tool.helpers.js";
const DEFAULT_PROMPT = "Describe the image.";
type ImageModelConfig = { primary?: string; fallbacks?: string[] };
function decodeDataUrl(dataUrl: string): {
buffer: Buffer;
mimeType: string;
kind: "image";
} {
const trimmed = dataUrl.trim();
const match = /^data:([^;,]+);base64,([a-z0-9+/=\r\n]+)$/i.exec(trimmed);
if (!match) throw new Error("Invalid data URL (expected base64 data: URL).");
const mimeType = (match[1] ?? "").trim().toLowerCase();
if (!mimeType.startsWith("image/")) {
throw new Error(`Unsupported data URL type: ${mimeType || "unknown"}`);
}
const b64 = (match[2] ?? "").trim();
const buffer = Buffer.from(b64, "base64");
if (buffer.length === 0) {
throw new Error("Invalid data URL: empty payload.");
}
return { buffer, mimeType, kind: "image" };
}
export const __testing = {
decodeDataUrl,
coerceImageAssistantText,
} as const;
function coerceImageAssistantText(params: {
message: AssistantMessage;
provider: string;
model: string;
}): string {
const stop = params.message.stopReason;
const errorMessage = params.message.errorMessage?.trim();
if (stop === "error" || stop === "aborted") {
throw new Error(
errorMessage
? `Image model failed (${params.provider}/${params.model}): ${errorMessage}`
: `Image model failed (${params.provider}/${params.model})`,
);
}
if (errorMessage) {
throw new Error(
`Image model failed (${params.provider}/${params.model}): ${errorMessage}`,
);
}
const text = extractAssistantText(params.message);
if (text.trim()) return text.trim();
throw new Error(
`Image model returned no text (${params.provider}/${params.model}).`,
);
}
function coerceImageModelConfig(cfg?: ClawdbotConfig): ImageModelConfig {
const imageModel = cfg?.agents?.defaults?.imageModel as
| { primary?: string; fallbacks?: string[] }
| string
| undefined;
const primary =
typeof imageModel === "string" ? imageModel.trim() : imageModel?.primary;
const fallbacks =
typeof imageModel === "object" ? (imageModel?.fallbacks ?? []) : [];
return {
...(primary?.trim() ? { primary: primary.trim() } : {}),
...(fallbacks.length > 0 ? { fallbacks } : {}),
};
}
function resolveProviderVisionModelFromConfig(params: {
cfg?: ClawdbotConfig;
provider: string;
}): string | null {
const providerCfg = params.cfg?.models?.providers?.[
params.provider
] as unknown as
| { models?: Array<{ id?: string; input?: string[] }> }
| undefined;
const models = providerCfg?.models ?? [];
const preferMinimaxVl =
params.provider === "minimax"
? models.find(
(m) =>
(m?.id ?? "").trim() === "MiniMax-VL-01" &&
Array.isArray(m?.input) &&
m.input.includes("image"),
)
: null;
const picked =
preferMinimaxVl ??
models.find(
(m) => Boolean((m?.id ?? "").trim()) && m.input?.includes("image"),
);
const id = (picked?.id ?? "").trim();
return id ? `${params.provider}/${id}` : null;
}
function resolveDefaultModelRef(cfg?: ClawdbotConfig): {
provider: string;
model: string;
@@ -446,6 +361,37 @@ export function createImageTool(options?: {
? imageRawInput.slice(1).trim()
: imageRawInput;
if (!imageRaw) throw new Error("image required");
// The tool accepts file paths, file/data URLs, or http(s) URLs. In some
// agent/model contexts, images can be referenced as pseudo-URIs like
// `image:0` (e.g. "first image in the prompt"). We don't have access to a
// shared image registry here, so fail gracefully instead of attempting to
// `fs.readFile("image:0")` and producing a noisy ENOENT.
const looksLikeWindowsDrivePath = /^[a-zA-Z]:[\\/]/.test(imageRaw);
const hasScheme = /^[a-z][a-z0-9+.-]*:/i.test(imageRaw);
const isFileUrl = /^file:/i.test(imageRaw);
const isHttpUrl = /^https?:\/\//i.test(imageRaw);
const isDataUrl = /^data:/i.test(imageRaw);
if (
hasScheme &&
!looksLikeWindowsDrivePath &&
!isFileUrl &&
!isHttpUrl &&
!isDataUrl
) {
return {
content: [
{
type: "text",
text: `Unsupported image reference: ${imageRawInput}. Use a file path, a file:// URL, a data: URL, or an http(s) URL.`,
},
],
details: {
error: "unsupported_image_reference",
image: imageRawInput,
},
};
}
const promptRaw =
typeof record.prompt === "string" && record.prompt.trim()
? record.prompt.trim()
@@ -459,12 +405,11 @@ export function createImageTool(options?: {
const maxBytes = pickMaxBytes(options?.config, maxBytesMb);
const sandboxRoot = options?.sandboxRoot?.trim();
const isUrl = /^https?:\/\//i.test(imageRaw);
const isUrl = isHttpUrl;
if (sandboxRoot && isUrl) {
throw new Error("Sandboxed image tool does not allow remote URLs.");
}
const isDataUrl = /^data:/i.test(imageRaw);
const resolvedImage = (() => {
if (sandboxRoot) return imageRaw;
if (imageRaw.startsWith("~")) return resolveUserPath(imageRaw);

View File

@@ -0,0 +1,148 @@
import crypto from "node:crypto";
import { callGateway } from "../../gateway/call.js";
import { formatErrorMessage } from "../../infra/errors.js";
import { createSubsystemLogger } from "../../logging.js";
import type { GatewayMessageChannel } from "../../utils/message-channel.js";
import { AGENT_LANE_NESTED } from "../lanes.js";
import { readLatestAssistantReply, runAgentStep } from "./agent-step.js";
import { resolveAnnounceTarget } from "./sessions-announce-target.js";
import {
buildAgentToAgentAnnounceContext,
buildAgentToAgentReplyContext,
isAnnounceSkip,
isReplySkip,
} from "./sessions-send-helpers.js";
const log = createSubsystemLogger("agents/sessions-send");
export async function runSessionsSendA2AFlow(params: {
targetSessionKey: string;
displayKey: string;
message: string;
announceTimeoutMs: number;
maxPingPongTurns: number;
requesterSessionKey?: string;
requesterChannel?: GatewayMessageChannel;
roundOneReply?: string;
waitRunId?: string;
}) {
const runContextId = params.waitRunId ?? "unknown";
try {
let primaryReply = params.roundOneReply;
let latestReply = params.roundOneReply;
if (!primaryReply && params.waitRunId) {
const waitMs = Math.min(params.announceTimeoutMs, 60_000);
const wait = (await callGateway({
method: "agent.wait",
params: {
runId: params.waitRunId,
timeoutMs: waitMs,
},
timeoutMs: waitMs + 2000,
})) as { status?: string };
if (wait?.status === "ok") {
primaryReply = await readLatestAssistantReply({
sessionKey: params.targetSessionKey,
});
latestReply = primaryReply;
}
}
if (!latestReply) return;
const announceTarget = await resolveAnnounceTarget({
sessionKey: params.targetSessionKey,
displayKey: params.displayKey,
});
const targetChannel = announceTarget?.channel ?? "unknown";
if (
params.maxPingPongTurns > 0 &&
params.requesterSessionKey &&
params.requesterSessionKey !== params.targetSessionKey
) {
let currentSessionKey = params.requesterSessionKey;
let nextSessionKey = params.targetSessionKey;
let incomingMessage = latestReply;
for (let turn = 1; turn <= params.maxPingPongTurns; turn += 1) {
const currentRole =
currentSessionKey === params.requesterSessionKey
? "requester"
: "target";
const replyPrompt = buildAgentToAgentReplyContext({
requesterSessionKey: params.requesterSessionKey,
requesterChannel: params.requesterChannel,
targetSessionKey: params.displayKey,
targetChannel,
currentRole,
turn,
maxTurns: params.maxPingPongTurns,
});
const replyText = await runAgentStep({
sessionKey: currentSessionKey,
message: incomingMessage,
extraSystemPrompt: replyPrompt,
timeoutMs: params.announceTimeoutMs,
lane: AGENT_LANE_NESTED,
});
if (!replyText || isReplySkip(replyText)) {
break;
}
latestReply = replyText;
incomingMessage = replyText;
const swap = currentSessionKey;
currentSessionKey = nextSessionKey;
nextSessionKey = swap;
}
}
const announcePrompt = buildAgentToAgentAnnounceContext({
requesterSessionKey: params.requesterSessionKey,
requesterChannel: params.requesterChannel,
targetSessionKey: params.displayKey,
targetChannel,
originalMessage: params.message,
roundOneReply: primaryReply,
latestReply,
});
const announceReply = await runAgentStep({
sessionKey: params.targetSessionKey,
message: "Agent-to-agent announce step.",
extraSystemPrompt: announcePrompt,
timeoutMs: params.announceTimeoutMs,
lane: AGENT_LANE_NESTED,
});
if (
announceTarget &&
announceReply &&
announceReply.trim() &&
!isAnnounceSkip(announceReply)
) {
try {
await callGateway({
method: "send",
params: {
to: announceTarget.to,
message: announceReply.trim(),
channel: announceTarget.channel,
accountId: announceTarget.accountId,
idempotencyKey: crypto.randomUUID(),
},
timeoutMs: 10_000,
});
} catch (err) {
log.warn("sessions_send announce delivery failed", {
runId: runContextId,
channel: announceTarget.channel,
to: announceTarget.to,
error: formatErrorMessage(err),
});
}
}
} catch (err) {
log.warn("sessions_send announce flow failed", {
runId: runContextId,
error: formatErrorMessage(err),
});
}
}

View File

@@ -4,8 +4,6 @@ import { Type } from "@sinclair/typebox";
import { loadConfig } from "../../config/config.js";
import { callGateway } from "../../gateway/call.js";
import { formatErrorMessage } from "../../infra/errors.js";
import { createSubsystemLogger } from "../../logging.js";
import {
isSubagentSessionKey,
normalizeAgentId,
@@ -17,10 +15,8 @@ import {
INTERNAL_MESSAGE_CHANNEL,
} from "../../utils/message-channel.js";
import { AGENT_LANE_NESTED } from "../lanes.js";
import { readLatestAssistantReply, runAgentStep } from "./agent-step.js";
import type { AnyAgentTool } from "./common.js";
import { jsonResult, readStringParam } from "./common.js";
import { resolveAnnounceTarget } from "./sessions-announce-target.js";
import {
extractAssistantText,
resolveDisplaySessionKey,
@@ -29,15 +25,10 @@ import {
stripToolMessages,
} from "./sessions-helpers.js";
import {
buildAgentToAgentAnnounceContext,
buildAgentToAgentMessageContext,
buildAgentToAgentReplyContext,
isAnnounceSkip,
isReplySkip,
resolvePingPongTurns,
} from "./sessions-send-helpers.js";
const log = createSubsystemLogger("agents/sessions-send");
import { runSessionsSendA2AFlow } from "./sessions-send-tool.a2a.js";
const SessionsSendToolSchema = Type.Object({
sessionKey: Type.Optional(Type.String()),
@@ -313,126 +304,18 @@ export function createSessionsSendTool(opts?: {
const requesterChannel = opts?.agentChannel;
const maxPingPongTurns = resolvePingPongTurns(cfg);
const delivery = { status: "pending", mode: "announce" as const };
const runAgentToAgentFlow = async (
roundOneReply?: string,
runInfo?: { runId: string },
) => {
const runContextId = runInfo?.runId ?? runId;
try {
let primaryReply = roundOneReply;
let latestReply = roundOneReply;
if (!primaryReply && runInfo?.runId) {
const waitMs = Math.min(announceTimeoutMs, 60_000);
const wait = (await callGateway({
method: "agent.wait",
params: {
runId: runInfo.runId,
timeoutMs: waitMs,
},
timeoutMs: waitMs + 2000,
})) as { status?: string };
if (wait?.status === "ok") {
primaryReply = await readLatestAssistantReply({
sessionKey: resolvedKey,
});
latestReply = primaryReply;
}
}
if (!latestReply) return;
const announceTarget = await resolveAnnounceTarget({
sessionKey: resolvedKey,
displayKey,
});
const targetChannel = announceTarget?.channel ?? "unknown";
if (
maxPingPongTurns > 0 &&
requesterSessionKey &&
requesterSessionKey !== resolvedKey
) {
let currentSessionKey = requesterSessionKey;
let nextSessionKey = resolvedKey;
let incomingMessage = latestReply;
for (let turn = 1; turn <= maxPingPongTurns; turn += 1) {
const currentRole =
currentSessionKey === requesterSessionKey
? "requester"
: "target";
const replyPrompt = buildAgentToAgentReplyContext({
requesterSessionKey,
requesterChannel,
targetSessionKey: displayKey,
targetChannel,
currentRole,
turn,
maxTurns: maxPingPongTurns,
});
const replyText = await runAgentStep({
sessionKey: currentSessionKey,
message: incomingMessage,
extraSystemPrompt: replyPrompt,
timeoutMs: announceTimeoutMs,
lane: AGENT_LANE_NESTED,
});
if (!replyText || isReplySkip(replyText)) {
break;
}
latestReply = replyText;
incomingMessage = replyText;
const swap = currentSessionKey;
currentSessionKey = nextSessionKey;
nextSessionKey = swap;
}
}
const announcePrompt = buildAgentToAgentAnnounceContext({
requesterSessionKey,
requesterChannel,
targetSessionKey: displayKey,
targetChannel,
originalMessage: message,
roundOneReply: primaryReply,
latestReply,
});
const announceReply = await runAgentStep({
sessionKey: resolvedKey,
message: "Agent-to-agent announce step.",
extraSystemPrompt: announcePrompt,
timeoutMs: announceTimeoutMs,
lane: AGENT_LANE_NESTED,
});
if (
announceTarget &&
announceReply &&
announceReply.trim() &&
!isAnnounceSkip(announceReply)
) {
try {
await callGateway({
method: "send",
params: {
to: announceTarget.to,
message: announceReply.trim(),
channel: announceTarget.channel,
accountId: announceTarget.accountId,
idempotencyKey: crypto.randomUUID(),
},
timeoutMs: 10_000,
});
} catch (err) {
log.warn("sessions_send announce delivery failed", {
runId: runContextId,
channel: announceTarget.channel,
to: announceTarget.to,
error: formatErrorMessage(err),
});
}
}
} catch (err) {
log.warn("sessions_send announce flow failed", {
runId: runContextId,
error: formatErrorMessage(err),
});
}
const startA2AFlow = (roundOneReply?: string, waitRunId?: string) => {
void runSessionsSendA2AFlow({
targetSessionKey: resolvedKey,
displayKey,
message,
announceTimeoutMs,
maxPingPongTurns,
requesterSessionKey,
requesterChannel,
roundOneReply,
waitRunId,
});
};
if (timeoutSeconds === 0) {
@@ -445,7 +328,7 @@ export function createSessionsSendTool(opts?: {
if (typeof response?.runId === "string" && response.runId) {
runId = response.runId;
}
void runAgentToAgentFlow(undefined, { runId });
startA2AFlow(undefined, runId);
return jsonResult({
runId,
status: "accepted",
@@ -547,7 +430,7 @@ export function createSessionsSendTool(opts?: {
const last =
filtered.length > 0 ? filtered[filtered.length - 1] : undefined;
const reply = last ? extractAssistantText(last) : undefined;
void runAgentToAgentFlow(reply ?? undefined);
startA2AFlow(reply ?? undefined);
return jsonResult({
runId,