feat: multi-agent routing + multi-account providers

This commit is contained in:
Peter Steinberger
2026-01-06 18:25:37 +00:00
parent 50d4b17417
commit dbfa316d19
129 changed files with 3760 additions and 1126 deletions

View File

@@ -14,7 +14,6 @@ import { Type } from "@sinclair/typebox";
import type { ClawdbotConfig } from "../../config/config.js";
import { resolveUserPath } from "../../utils.js";
import { loadWebMedia } from "../../web/media.js";
import { resolveClawdbotAgentDir } from "../agent-paths.js";
import { getApiKeyForModel } from "../model-auth.js";
import { runWithImageModelFallback } from "../model-fallback.js";
import { ensureClawdbotModelsJson } from "../models-config.js";
@@ -78,15 +77,15 @@ function buildImageContext(
async function runImagePrompt(params: {
cfg?: ClawdbotConfig;
agentDir: string;
modelOverride?: string;
prompt: string;
base64: string;
mimeType: string;
}): Promise<{ text: string; provider: string; model: string }> {
const agentDir = resolveClawdbotAgentDir();
await ensureClawdbotModelsJson(params.cfg);
const authStorage = discoverAuthStorage(agentDir);
const modelRegistry = discoverModels(authStorage, agentDir);
await ensureClawdbotModelsJson(params.cfg, params.agentDir);
const authStorage = discoverAuthStorage(params.agentDir);
const modelRegistry = discoverModels(authStorage, params.agentDir);
const result = await runWithImageModelFallback({
cfg: params.cfg,
@@ -104,6 +103,7 @@ async function runImagePrompt(params: {
const apiKeyInfo = await getApiKeyForModel({
model,
cfg: params.cfg,
agentDir: params.agentDir,
});
authStorage.setRuntimeApiKey(model.provider, apiKeyInfo.apiKey);
const context = buildImageContext(
@@ -130,8 +130,13 @@ async function runImagePrompt(params: {
export function createImageTool(options?: {
config?: ClawdbotConfig;
agentDir?: string;
}): AnyAgentTool | null {
if (!ensureImageToolConfigured(options?.config)) return null;
const agentDir = options?.agentDir;
if (!agentDir?.trim()) {
throw new Error("createImageTool requires agentDir when enabled");
}
return {
label: "Image",
name: "image",
@@ -175,6 +180,7 @@ export function createImageTool(options?: {
const base64 = media.buffer.toString("base64");
const result = await runImagePrompt({
cfg: options?.config,
agentDir,
modelOverride,
prompt: promptRaw,
base64,

View File

@@ -0,0 +1,52 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const callGatewayMock = vi.fn();
vi.mock("../../gateway/call.js", () => ({
callGateway: (opts: unknown) => callGatewayMock(opts),
}));
import { resolveAnnounceTarget } from "./sessions-announce-target.js";
describe("resolveAnnounceTarget", () => {
beforeEach(() => {
callGatewayMock.mockReset();
});
it("derives non-WhatsApp announce targets from the session key", async () => {
const target = await resolveAnnounceTarget({
sessionKey: "agent:main:discord:group:dev",
displayKey: "agent:main:discord:group:dev",
});
expect(target).toEqual({ provider: "discord", to: "channel:dev" });
expect(callGatewayMock).not.toHaveBeenCalled();
});
it("hydrates WhatsApp accountId from sessions.list when available", async () => {
callGatewayMock.mockResolvedValueOnce({
sessions: [
{
key: "agent:main:whatsapp:group:123@g.us",
lastProvider: "whatsapp",
lastTo: "123@g.us",
lastAccountId: "work",
},
],
});
const target = await resolveAnnounceTarget({
sessionKey: "agent:main:whatsapp:group:123@g.us",
displayKey: "agent:main:whatsapp:group:123@g.us",
});
expect(target).toEqual({
provider: "whatsapp",
to: "123@g.us",
accountId: "work",
});
expect(callGatewayMock).toHaveBeenCalledTimes(1);
const first = callGatewayMock.mock.calls[0]?.[0] as
| { method?: string }
| undefined;
expect(first).toBeDefined();
expect(first?.method).toBe("sessions.list");
});
});

View File

@@ -7,9 +7,12 @@ export async function resolveAnnounceTarget(params: {
displayKey: string;
}): Promise<AnnounceTarget | null> {
const parsed = resolveAnnounceTargetFromKey(params.sessionKey);
if (parsed) return parsed;
const parsedDisplay = resolveAnnounceTargetFromKey(params.displayKey);
if (parsedDisplay) return parsedDisplay;
const fallback = parsed ?? parsedDisplay ?? null;
// Most providers can derive (provider,to) from the session key directly.
// WhatsApp is special: we may need lastAccountId from the session store.
if (fallback && fallback.provider !== "whatsapp") return fallback;
try {
const list = (await callGateway({
@@ -24,13 +27,17 @@ export async function resolveAnnounceTarget(params: {
const match =
sessions.find((entry) => entry?.key === params.sessionKey) ??
sessions.find((entry) => entry?.key === params.displayKey);
const channel =
typeof match?.lastChannel === "string" ? match.lastChannel : undefined;
const provider =
typeof match?.lastProvider === "string" ? match.lastProvider : undefined;
const to = typeof match?.lastTo === "string" ? match.lastTo : undefined;
if (channel && to) return { channel, to };
const accountId =
typeof match?.lastAccountId === "string"
? match.lastAccountId
: undefined;
if (provider && to) return { provider, to, accountId };
} catch {
// ignore
}
return null;
return fallback;
}

View File

@@ -58,8 +58,8 @@ export function classifySessionKind(params: {
export function deriveProvider(params: {
key: string;
kind: SessionKind;
surface?: string | null;
lastChannel?: string | null;
provider?: string | null;
lastProvider?: string | null;
}): string {
if (
params.kind === "cron" ||
@@ -67,10 +67,10 @@ export function deriveProvider(params: {
params.kind === "node"
)
return "internal";
const surface = normalizeKey(params.surface ?? undefined);
if (surface) return surface;
const lastChannel = normalizeKey(params.lastChannel ?? undefined);
if (lastChannel) return lastChannel;
const provider = normalizeKey(params.provider ?? undefined);
if (provider) return provider;
const lastProvider = normalizeKey(params.lastProvider ?? undefined);
if (lastProvider) return lastProvider;
const parts = params.key.split(":").filter(Boolean);
if (parts.length >= 3 && (parts[1] === "group" || parts[1] === "channel")) {
return parts[0];

View File

@@ -2,6 +2,11 @@ import { Type } from "@sinclair/typebox";
import { loadConfig } from "../../config/config.js";
import { callGateway } from "../../gateway/call.js";
import {
isSubagentSessionKey,
normalizeAgentId,
parseAgentSessionKey,
} from "../../routing/session-key.js";
import type { AnyAgentTool } from "./common.js";
import { jsonResult, readStringParam } from "./common.js";
import {
@@ -78,7 +83,7 @@ export function createSessionsHistoryTool(opts?: {
opts?.sandboxed === true &&
visibility === "spawned" &&
requesterInternalKey &&
!requesterInternalKey.toLowerCase().startsWith("subagent:");
!isSubagentSessionKey(requesterInternalKey);
if (restrictToSpawned) {
const ok = await isSpawnedSessionAllowed({
requesterSessionKey: requesterInternalKey,
@@ -91,6 +96,48 @@ export function createSessionsHistoryTool(opts?: {
});
}
}
const routingA2A = cfg.routing?.agentToAgent;
const a2aEnabled = routingA2A?.enabled === true;
const allowPatterns = Array.isArray(routingA2A?.allow)
? routingA2A.allow
: [];
const matchesAllow = (agentId: string) => {
if (allowPatterns.length === 0) return true;
return allowPatterns.some((pattern) => {
const raw = String(pattern ?? "").trim();
if (!raw) return false;
if (raw === "*") return true;
if (!raw.includes("*")) return raw === agentId;
const escaped = raw.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const re = new RegExp(`^${escaped.replaceAll("\\*", ".*")}$`, "i");
return re.test(agentId);
});
};
const requesterAgentId = normalizeAgentId(
parseAgentSessionKey(requesterInternalKey)?.agentId,
);
const targetAgentId = normalizeAgentId(
parseAgentSessionKey(resolvedKey)?.agentId,
);
const isCrossAgent = requesterAgentId !== targetAgentId;
if (isCrossAgent) {
if (!a2aEnabled) {
return jsonResult({
status: "forbidden",
error:
"Agent-to-agent history is disabled. Set routing.agentToAgent.enabled=true to allow cross-agent access.",
});
}
if (!matchesAllow(requesterAgentId) || !matchesAllow(targetAgentId)) {
return jsonResult({
status: "forbidden",
error:
"Agent-to-agent history denied by routing.agentToAgent.allow.",
});
}
}
const limit =
typeof params.limit === "number" && Number.isFinite(params.limit)
? Math.max(1, Math.floor(params.limit))

View File

@@ -0,0 +1,43 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const callGatewayMock = vi.fn();
vi.mock("../../gateway/call.js", () => ({
callGateway: (opts: unknown) => callGatewayMock(opts),
}));
vi.mock("../../config/config.js", async (importOriginal) => {
const actual =
await importOriginal<typeof import("../../config/config.js")>();
return {
...actual,
loadConfig: () =>
({
session: { scope: "per-sender", mainKey: "main" },
routing: { agentToAgent: { enabled: false } },
}) as never,
};
});
import { createSessionsListTool } from "./sessions-list-tool.js";
describe("sessions_list gating", () => {
beforeEach(() => {
callGatewayMock.mockReset();
callGatewayMock.mockResolvedValue({
path: "/tmp/sessions.json",
sessions: [
{ key: "agent:main:main", kind: "direct" },
{ key: "agent:other:main", kind: "direct" },
],
});
});
it("filters out other agents when routing.agentToAgent.enabled is false", async () => {
const tool = createSessionsListTool({ agentSessionKey: "agent:main:main" });
const result = await tool.execute("call1", {});
expect(result.details).toMatchObject({
count: 1,
sessions: [{ key: "agent:main:main" }],
});
});
});

View File

@@ -4,6 +4,11 @@ import { Type } from "@sinclair/typebox";
import { loadConfig } from "../../config/config.js";
import { callGateway } from "../../gateway/call.js";
import {
isSubagentSessionKey,
normalizeAgentId,
parseAgentSessionKey,
} from "../../routing/session-key.js";
import type { AnyAgentTool } from "./common.js";
import { jsonResult, readStringArrayParam } from "./common.js";
import {
@@ -31,8 +36,9 @@ type SessionListRow = {
systemSent?: boolean;
abortedLastRun?: boolean;
sendPolicy?: string;
lastChannel?: string;
lastProvider?: string;
lastTo?: string;
lastAccountId?: string;
transcriptPath?: string;
messages?: unknown[];
};
@@ -76,7 +82,7 @@ export function createSessionsListTool(opts?: {
opts?.sandboxed === true &&
visibility === "spawned" &&
requesterInternalKey &&
!requesterInternalKey.toLowerCase().startsWith("subagent:");
!isSubagentSessionKey(requesterInternalKey);
const kindsRaw = readStringArrayParam(params, "kinds")?.map((value) =>
value.trim().toLowerCase(),
@@ -120,12 +126,43 @@ export function createSessionsListTool(opts?: {
const sessions = Array.isArray(list?.sessions) ? list.sessions : [];
const storePath = typeof list?.path === "string" ? list.path : undefined;
const routingA2A = cfg.routing?.agentToAgent;
const a2aEnabled = routingA2A?.enabled === true;
const allowPatterns = Array.isArray(routingA2A?.allow)
? routingA2A.allow
: [];
const matchesAllow = (agentId: string) => {
if (allowPatterns.length === 0) return true;
return allowPatterns.some((pattern) => {
const raw = String(pattern ?? "").trim();
if (!raw) return false;
if (raw === "*") return true;
if (!raw.includes("*")) return raw === agentId;
const escaped = raw.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const re = new RegExp(`^${escaped.replaceAll("\\*", ".*")}$`, "i");
return re.test(agentId);
});
};
const requesterAgentId = normalizeAgentId(
parseAgentSessionKey(requesterInternalKey)?.agentId,
);
const rows: SessionListRow[] = [];
for (const entry of sessions) {
if (!entry || typeof entry !== "object") continue;
const key = typeof entry.key === "string" ? entry.key : "";
if (!key) continue;
const entryAgentId = normalizeAgentId(
parseAgentSessionKey(key)?.agentId,
);
const crossAgent = entryAgentId !== requesterAgentId;
if (crossAgent) {
if (!a2aEnabled) continue;
if (!matchesAllow(requesterAgentId) || !matchesAllow(entryAgentId))
continue;
}
if (key === "unknown") continue;
if (key === "global" && alias !== "global") continue;
@@ -140,15 +177,21 @@ export function createSessionsListTool(opts?: {
mainKey,
});
const surface =
typeof entry.surface === "string" ? entry.surface : undefined;
const lastChannel =
typeof entry.lastChannel === "string" ? entry.lastChannel : undefined;
const provider = deriveProvider({
const entryProvider =
typeof entry.provider === "string" ? entry.provider : undefined;
const lastProvider =
typeof entry.lastProvider === "string"
? entry.lastProvider
: undefined;
const lastAccountId =
typeof entry.lastAccountId === "string"
? entry.lastAccountId
: undefined;
const derivedProvider = deriveProvider({
key,
kind,
surface,
lastChannel,
provider: entryProvider,
lastProvider,
});
const sessionId =
@@ -161,7 +204,7 @@ export function createSessionsListTool(opts?: {
const row: SessionListRow = {
key: displayKey,
kind,
provider,
provider: derivedProvider,
displayName:
typeof entry.displayName === "string"
? entry.displayName
@@ -196,8 +239,9 @@ export function createSessionsListTool(opts?: {
: undefined,
sendPolicy:
typeof entry.sendPolicy === "string" ? entry.sendPolicy : undefined,
lastChannel,
lastProvider,
lastTo: typeof entry.lastTo === "string" ? entry.lastTo : undefined,
lastAccountId,
transcriptPath,
};

View File

@@ -6,33 +6,38 @@ const DEFAULT_PING_PONG_TURNS = 5;
const MAX_PING_PONG_TURNS = 5;
export type AnnounceTarget = {
channel: string;
provider: string;
to: string;
accountId?: string;
};
export function resolveAnnounceTargetFromKey(
sessionKey: string,
): AnnounceTarget | null {
const parts = sessionKey.split(":").filter(Boolean);
const rawParts = sessionKey.split(":").filter(Boolean);
const parts =
rawParts.length >= 3 && rawParts[0] === "agent"
? rawParts.slice(2)
: rawParts;
if (parts.length < 3) return null;
const [surface, kind, ...rest] = parts;
const [providerRaw, kind, ...rest] = parts;
if (kind !== "group" && kind !== "channel") return null;
const id = rest.join(":").trim();
if (!id) return null;
if (!surface) return null;
const channel = surface.toLowerCase();
if (channel === "discord") {
return { channel, to: `channel:${id}` };
if (!providerRaw) return null;
const provider = providerRaw.toLowerCase();
if (provider === "discord") {
return { provider, to: `channel:${id}` };
}
if (channel === "signal") {
return { channel, to: `group:${id}` };
if (provider === "signal") {
return { provider, to: `group:${id}` };
}
return { channel, to: id };
return { provider, to: id };
}
export function buildAgentToAgentMessageContext(params: {
requesterSessionKey?: string;
requesterSurface?: string;
requesterProvider?: string;
targetSessionKey: string;
}) {
const lines = [
@@ -40,8 +45,8 @@ export function buildAgentToAgentMessageContext(params: {
params.requesterSessionKey
? `Agent 1 (requester) session: ${params.requesterSessionKey}.`
: undefined,
params.requesterSurface
? `Agent 1 (requester) surface: ${params.requesterSurface}.`
params.requesterProvider
? `Agent 1 (requester) provider: ${params.requesterProvider}.`
: undefined,
`Agent 2 (target) session: ${params.targetSessionKey}.`,
].filter(Boolean);
@@ -50,9 +55,9 @@ export function buildAgentToAgentMessageContext(params: {
export function buildAgentToAgentReplyContext(params: {
requesterSessionKey?: string;
requesterSurface?: string;
requesterProvider?: string;
targetSessionKey: string;
targetChannel?: string;
targetProvider?: string;
currentRole: "requester" | "target";
turn: number;
maxTurns: number;
@@ -68,12 +73,12 @@ export function buildAgentToAgentReplyContext(params: {
params.requesterSessionKey
? `Agent 1 (requester) session: ${params.requesterSessionKey}.`
: undefined,
params.requesterSurface
? `Agent 1 (requester) surface: ${params.requesterSurface}.`
params.requesterProvider
? `Agent 1 (requester) provider: ${params.requesterProvider}.`
: undefined,
`Agent 2 (target) session: ${params.targetSessionKey}.`,
params.targetChannel
? `Agent 2 (target) surface: ${params.targetChannel}.`
params.targetProvider
? `Agent 2 (target) provider: ${params.targetProvider}.`
: undefined,
`If you want to stop the ping-pong, reply exactly "${REPLY_SKIP_TOKEN}".`,
].filter(Boolean);
@@ -82,9 +87,9 @@ export function buildAgentToAgentReplyContext(params: {
export function buildAgentToAgentAnnounceContext(params: {
requesterSessionKey?: string;
requesterSurface?: string;
requesterProvider?: string;
targetSessionKey: string;
targetChannel?: string;
targetProvider?: string;
originalMessage: string;
roundOneReply?: string;
latestReply?: string;
@@ -94,12 +99,12 @@ export function buildAgentToAgentAnnounceContext(params: {
params.requesterSessionKey
? `Agent 1 (requester) session: ${params.requesterSessionKey}.`
: undefined,
params.requesterSurface
? `Agent 1 (requester) surface: ${params.requesterSurface}.`
params.requesterProvider
? `Agent 1 (requester) provider: ${params.requesterProvider}.`
: undefined,
`Agent 2 (target) session: ${params.targetSessionKey}.`,
params.targetChannel
? `Agent 2 (target) surface: ${params.targetChannel}.`
params.targetProvider
? `Agent 2 (target) provider: ${params.targetProvider}.`
: undefined,
`Original request: ${params.originalMessage}`,
params.roundOneReply
@@ -109,7 +114,7 @@ export function buildAgentToAgentAnnounceContext(params: {
? `Latest reply: ${params.latestReply}`
: "Latest reply: (not available).",
`If you want to remain silent, reply exactly "${ANNOUNCE_SKIP_TOKEN}".`,
"Any other reply will be posted to the target channel.",
"Any other reply will be posted to the target provider.",
"After this reply, the agent-to-agent conversation is over.",
].filter(Boolean);
return lines.join("\n");

View File

@@ -0,0 +1,43 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const callGatewayMock = vi.fn();
vi.mock("../../gateway/call.js", () => ({
callGateway: (opts: unknown) => callGatewayMock(opts),
}));
vi.mock("../../config/config.js", async (importOriginal) => {
const actual =
await importOriginal<typeof import("../../config/config.js")>();
return {
...actual,
loadConfig: () =>
({
session: { scope: "per-sender", mainKey: "main" },
routing: { agentToAgent: { enabled: false } },
}) as never,
};
});
import { createSessionsSendTool } from "./sessions-send-tool.js";
describe("sessions_send gating", () => {
beforeEach(() => {
callGatewayMock.mockReset();
});
it("blocks cross-agent sends when routing.agentToAgent.enabled is false", async () => {
const tool = createSessionsSendTool({
agentSessionKey: "agent:main:main",
agentProvider: "whatsapp",
});
const result = await tool.execute("call1", {
sessionKey: "agent:other:main",
message: "hi",
timeoutSeconds: 0,
});
expect(callGatewayMock).not.toHaveBeenCalled();
expect(result.details).toMatchObject({ status: "forbidden" });
});
});

View File

@@ -4,6 +4,11 @@ import { Type } from "@sinclair/typebox";
import { loadConfig } from "../../config/config.js";
import { callGateway } from "../../gateway/call.js";
import {
isSubagentSessionKey,
normalizeAgentId,
parseAgentSessionKey,
} from "../../routing/session-key.js";
import { readLatestAssistantReply, runAgentStep } from "./agent-step.js";
import type { AnyAgentTool } from "./common.js";
import { jsonResult, readStringParam } from "./common.js";
@@ -32,7 +37,7 @@ const SessionsSendToolSchema = Type.Object({
export function createSessionsSendTool(opts?: {
agentSessionKey?: string;
agentSurface?: string;
agentProvider?: string;
sandboxed?: boolean;
}): AnyAgentTool {
return {
@@ -67,7 +72,7 @@ export function createSessionsSendTool(opts?: {
opts?.sandboxed === true &&
visibility === "spawned" &&
requesterInternalKey &&
!requesterInternalKey.toLowerCase().startsWith("subagent:");
!isSubagentSessionKey(requesterInternalKey);
if (restrictToSpawned) {
try {
const list = (await callGateway({
@@ -120,9 +125,55 @@ export function createSessionsSendTool(opts?: {
alias,
mainKey,
});
const routingA2A = cfg.routing?.agentToAgent;
const a2aEnabled = routingA2A?.enabled === true;
const allowPatterns = Array.isArray(routingA2A?.allow)
? routingA2A.allow
: [];
const matchesAllow = (agentId: string) => {
if (allowPatterns.length === 0) return true;
return allowPatterns.some((pattern) => {
const raw = String(pattern ?? "").trim();
if (!raw) return false;
if (raw === "*") return true;
if (!raw.includes("*")) return raw === agentId;
const escaped = raw.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const re = new RegExp(`^${escaped.replaceAll("\\*", ".*")}$`, "i");
return re.test(agentId);
});
};
const requesterAgentId = normalizeAgentId(
parseAgentSessionKey(requesterInternalKey)?.agentId,
);
const targetAgentId = normalizeAgentId(
parseAgentSessionKey(resolvedKey)?.agentId,
);
const isCrossAgent = requesterAgentId !== targetAgentId;
if (isCrossAgent) {
if (!a2aEnabled) {
return jsonResult({
runId: crypto.randomUUID(),
status: "forbidden",
error:
"Agent-to-agent messaging is disabled. Set routing.agentToAgent.enabled=true to allow cross-agent sends.",
sessionKey: displayKey,
});
}
if (!matchesAllow(requesterAgentId) || !matchesAllow(targetAgentId)) {
return jsonResult({
runId: crypto.randomUUID(),
status: "forbidden",
error:
"Agent-to-agent messaging denied by routing.agentToAgent.allow.",
sessionKey: displayKey,
});
}
}
const agentMessageContext = buildAgentToAgentMessageContext({
requesterSessionKey: opts?.agentSessionKey,
requesterSurface: opts?.agentSurface,
requesterProvider: opts?.agentProvider,
targetSessionKey: displayKey,
});
const sendParams = {
@@ -134,7 +185,7 @@ export function createSessionsSendTool(opts?: {
extraSystemPrompt: agentMessageContext,
};
const requesterSessionKey = opts?.agentSessionKey;
const requesterSurface = opts?.agentSurface;
const requesterProvider = opts?.agentProvider;
const maxPingPongTurns = resolvePingPongTurns(cfg);
const runAgentToAgentFlow = async (
@@ -166,7 +217,7 @@ export function createSessionsSendTool(opts?: {
sessionKey: resolvedKey,
displayKey,
});
const targetChannel = announceTarget?.channel ?? "unknown";
const targetProvider = announceTarget?.provider ?? "unknown";
if (
maxPingPongTurns > 0 &&
requesterSessionKey &&
@@ -182,9 +233,9 @@ export function createSessionsSendTool(opts?: {
: "target";
const replyPrompt = buildAgentToAgentReplyContext({
requesterSessionKey,
requesterSurface,
requesterProvider,
targetSessionKey: displayKey,
targetChannel,
targetProvider,
currentRole,
turn,
maxTurns: maxPingPongTurns,
@@ -208,9 +259,9 @@ export function createSessionsSendTool(opts?: {
}
const announcePrompt = buildAgentToAgentAnnounceContext({
requesterSessionKey,
requesterSurface,
requesterProvider,
targetSessionKey: displayKey,
targetChannel,
targetProvider,
originalMessage: message,
roundOneReply: primaryReply,
latestReply,
@@ -233,7 +284,8 @@ export function createSessionsSendTool(opts?: {
params: {
to: announceTarget.to,
message: announceReply.trim(),
provider: announceTarget.channel,
provider: announceTarget.provider,
accountId: announceTarget.accountId,
idempotencyKey: crypto.randomUUID(),
},
timeoutMs: 10_000,

View File

@@ -4,6 +4,11 @@ import { Type } from "@sinclair/typebox";
import { loadConfig } from "../../config/config.js";
import { callGateway } from "../../gateway/call.js";
import {
isSubagentSessionKey,
normalizeAgentId,
parseAgentSessionKey,
} from "../../routing/session-key.js";
import { readLatestAssistantReply, runAgentStep } from "./agent-step.js";
import type { AnyAgentTool } from "./common.js";
import { jsonResult, readStringParam } from "./common.js";
@@ -26,7 +31,7 @@ const SessionsSpawnToolSchema = Type.Object({
function buildSubagentSystemPrompt(params: {
requesterSessionKey?: string;
requesterSurface?: string;
requesterProvider?: string;
childSessionKey: string;
label?: string;
}) {
@@ -36,8 +41,8 @@ function buildSubagentSystemPrompt(params: {
params.requesterSessionKey
? `Requester session: ${params.requesterSessionKey}.`
: undefined,
params.requesterSurface
? `Requester surface: ${params.requesterSurface}.`
params.requesterProvider
? `Requester provider: ${params.requesterProvider}.`
: undefined,
`Your session: ${params.childSessionKey}.`,
"Run the task. Provide a clear final answer (plain text).",
@@ -48,7 +53,7 @@ function buildSubagentSystemPrompt(params: {
function buildSubagentAnnouncePrompt(params: {
requesterSessionKey?: string;
requesterSurface?: string;
requesterProvider?: string;
announceChannel: string;
task: string;
subagentReply?: string;
@@ -58,16 +63,16 @@ function buildSubagentAnnouncePrompt(params: {
params.requesterSessionKey
? `Requester session: ${params.requesterSessionKey}.`
: undefined,
params.requesterSurface
? `Requester surface: ${params.requesterSurface}.`
params.requesterProvider
? `Requester provider: ${params.requesterProvider}.`
: undefined,
`Post target surface: ${params.announceChannel}.`,
`Post target provider: ${params.announceChannel}.`,
`Original task: ${params.task}`,
params.subagentReply
? `Sub-agent result: ${params.subagentReply}`
: "Sub-agent result: (not available).",
'Reply exactly "ANNOUNCE_SKIP" to stay silent.',
"Any other reply will be posted to the requester chat surface.",
"Any other reply will be posted to the requester chat provider.",
].filter(Boolean);
return lines.join("\n");
}
@@ -76,7 +81,7 @@ async function runSubagentAnnounceFlow(params: {
childSessionKey: string;
childRunId: string;
requesterSessionKey: string;
requesterSurface?: string;
requesterProvider?: string;
requesterDisplayKey: string;
task: string;
timeoutMs: number;
@@ -109,8 +114,8 @@ async function runSubagentAnnounceFlow(params: {
const announcePrompt = buildSubagentAnnouncePrompt({
requesterSessionKey: params.requesterSessionKey,
requesterSurface: params.requesterSurface,
announceChannel: announceTarget.channel,
requesterProvider: params.requesterProvider,
announceChannel: announceTarget.provider,
task: params.task,
subagentReply: reply,
});
@@ -135,7 +140,8 @@ async function runSubagentAnnounceFlow(params: {
params: {
to: announceTarget.to,
message: announceReply.trim(),
provider: announceTarget.channel,
provider: announceTarget.provider,
accountId: announceTarget.accountId,
idempotencyKey: crypto.randomUUID(),
},
timeoutMs: 10_000,
@@ -159,7 +165,7 @@ async function runSubagentAnnounceFlow(params: {
export function createSessionsSpawnTool(opts?: {
agentSessionKey?: string;
agentSurface?: string;
agentProvider?: string;
sandboxed?: boolean;
}): AnyAgentTool {
return {
@@ -188,7 +194,7 @@ export function createSessionsSpawnTool(opts?: {
const requesterSessionKey = opts?.agentSessionKey;
if (
typeof requesterSessionKey === "string" &&
requesterSessionKey.trim().toLowerCase().startsWith("subagent:")
isSubagentSessionKey(requesterSessionKey)
) {
return jsonResult({
status: "forbidden",
@@ -208,7 +214,10 @@ export function createSessionsSpawnTool(opts?: {
mainKey,
});
const childSessionKey = `subagent:${crypto.randomUUID()}`;
const requesterAgentId = normalizeAgentId(
parseAgentSessionKey(requesterInternalKey)?.agentId,
);
const childSessionKey = `agent:${requesterAgentId}:subagent:${crypto.randomUUID()}`;
if (opts?.sandboxed === true) {
try {
await callGateway({
@@ -222,7 +231,7 @@ export function createSessionsSpawnTool(opts?: {
}
const childSystemPrompt = buildSubagentSystemPrompt({
requesterSessionKey,
requesterSurface: opts?.agentSurface,
requesterProvider: opts?.agentProvider,
childSessionKey,
label: label || undefined,
});
@@ -265,7 +274,7 @@ export function createSessionsSpawnTool(opts?: {
childSessionKey,
childRunId,
requesterSessionKey: requesterInternalKey,
requesterSurface: opts?.agentSurface,
requesterProvider: opts?.agentProvider,
requesterDisplayKey,
task,
timeoutMs: 30_000,
@@ -311,7 +320,7 @@ export function createSessionsSpawnTool(opts?: {
childSessionKey,
childRunId,
requesterSessionKey: requesterInternalKey,
requesterSurface: opts?.agentSurface,
requesterProvider: opts?.agentProvider,
requesterDisplayKey,
task,
timeoutMs: 30_000,
@@ -329,7 +338,7 @@ export function createSessionsSpawnTool(opts?: {
childSessionKey,
childRunId,
requesterSessionKey: requesterInternalKey,
requesterSurface: opts?.agentSurface,
requesterProvider: opts?.agentProvider,
requesterDisplayKey,
task,
timeoutMs: 30_000,
@@ -350,7 +359,7 @@ export function createSessionsSpawnTool(opts?: {
childSessionKey,
childRunId,
requesterSessionKey: requesterInternalKey,
requesterSurface: opts?.agentSurface,
requesterProvider: opts?.agentProvider,
requesterDisplayKey,
task,
timeoutMs: 30_000,