refactor(src): split oversized modules
This commit is contained in:
109
src/agents/tools/browser-tool.schema.ts
Normal file
109
src/agents/tools/browser-tool.schema.ts
Normal 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),
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
95
src/agents/tools/image-tool.helpers.ts
Normal file
95
src/agents/tools/image-tool.helpers.ts
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
148
src/agents/tools/sessions-send-tool.a2a.ts
Normal file
148
src/agents/tools/sessions-send-tool.a2a.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user