feat: multi-agent routing + multi-account providers
This commit is contained in:
55
src/agents/agent-scope.ts
Normal file
55
src/agents/agent-scope.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { resolveStateDir } from "../config/paths.js";
|
||||
import {
|
||||
DEFAULT_AGENT_ID,
|
||||
normalizeAgentId,
|
||||
parseAgentSessionKey,
|
||||
} from "../routing/session-key.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
import { DEFAULT_AGENT_WORKSPACE_DIR } from "./workspace.js";
|
||||
|
||||
export function resolveAgentIdFromSessionKey(
|
||||
sessionKey?: string | null,
|
||||
): string {
|
||||
const parsed = parseAgentSessionKey(sessionKey);
|
||||
return normalizeAgentId(parsed?.agentId ?? DEFAULT_AGENT_ID);
|
||||
}
|
||||
|
||||
export function resolveAgentConfig(
|
||||
cfg: ClawdbotConfig,
|
||||
agentId: string,
|
||||
): { workspace?: string; agentDir?: string } | undefined {
|
||||
const id = normalizeAgentId(agentId);
|
||||
const agents = cfg.routing?.agents;
|
||||
if (!agents || typeof agents !== "object") return undefined;
|
||||
const entry = agents[id];
|
||||
if (!entry || typeof entry !== "object") return undefined;
|
||||
return {
|
||||
workspace:
|
||||
typeof entry.workspace === "string" ? entry.workspace : undefined,
|
||||
agentDir: typeof entry.agentDir === "string" ? entry.agentDir : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveAgentWorkspaceDir(cfg: ClawdbotConfig, agentId: string) {
|
||||
const id = normalizeAgentId(agentId);
|
||||
const configured = resolveAgentConfig(cfg, id)?.workspace?.trim();
|
||||
if (configured) return resolveUserPath(configured);
|
||||
if (id === DEFAULT_AGENT_ID) {
|
||||
const legacy = cfg.agent?.workspace?.trim();
|
||||
if (legacy) return resolveUserPath(legacy);
|
||||
return DEFAULT_AGENT_WORKSPACE_DIR;
|
||||
}
|
||||
return path.join(os.homedir(), `clawd-${id}`);
|
||||
}
|
||||
|
||||
export function resolveAgentDir(cfg: ClawdbotConfig, agentId: string) {
|
||||
const id = normalizeAgentId(agentId);
|
||||
const configured = resolveAgentConfig(cfg, id)?.agentDir?.trim();
|
||||
if (configured) return resolveUserPath(configured);
|
||||
const root = resolveStateDir(process.env, os.homedir);
|
||||
return path.join(root, "agents", id, "agent");
|
||||
}
|
||||
@@ -49,14 +49,14 @@ export type AuthProfileStore = {
|
||||
|
||||
type LegacyAuthStore = Record<string, AuthProfileCredential>;
|
||||
|
||||
function resolveAuthStorePath(): string {
|
||||
const agentDir = resolveClawdbotAgentDir();
|
||||
return path.join(agentDir, AUTH_PROFILE_FILENAME);
|
||||
function resolveAuthStorePath(agentDir?: string): string {
|
||||
const resolved = resolveUserPath(agentDir ?? resolveClawdbotAgentDir());
|
||||
return path.join(resolved, AUTH_PROFILE_FILENAME);
|
||||
}
|
||||
|
||||
function resolveLegacyAuthStorePath(): string {
|
||||
const agentDir = resolveClawdbotAgentDir();
|
||||
return path.join(agentDir, LEGACY_AUTH_FILENAME);
|
||||
function resolveLegacyAuthStorePath(agentDir?: string): string {
|
||||
const resolved = resolveUserPath(agentDir ?? resolveClawdbotAgentDir());
|
||||
return path.join(resolved, LEGACY_AUTH_FILENAME);
|
||||
}
|
||||
|
||||
function loadJsonFile(pathname: string): unknown {
|
||||
@@ -104,8 +104,9 @@ function buildOAuthApiKey(
|
||||
async function refreshOAuthTokenWithLock(params: {
|
||||
profileId: string;
|
||||
provider: OAuthProvider;
|
||||
agentDir?: string;
|
||||
}): Promise<{ apiKey: string; newCredentials: OAuthCredentials } | null> {
|
||||
const authPath = resolveAuthStorePath();
|
||||
const authPath = resolveAuthStorePath(params.agentDir);
|
||||
ensureAuthStoreFile(authPath);
|
||||
|
||||
let release: (() => Promise<void>) | undefined;
|
||||
@@ -121,7 +122,7 @@ async function refreshOAuthTokenWithLock(params: {
|
||||
stale: 30_000,
|
||||
});
|
||||
|
||||
const store = ensureAuthProfileStore();
|
||||
const store = ensureAuthProfileStore(params.agentDir);
|
||||
const cred = store.profiles[params.profileId];
|
||||
if (!cred || cred.type !== "oauth") return null;
|
||||
|
||||
@@ -142,7 +143,7 @@ async function refreshOAuthTokenWithLock(params: {
|
||||
...result.newCredentials,
|
||||
type: "oauth",
|
||||
};
|
||||
saveAuthProfileStore(store);
|
||||
saveAuthProfileStore(store, params.agentDir);
|
||||
return result;
|
||||
} finally {
|
||||
if (release) {
|
||||
@@ -261,13 +262,13 @@ export function loadAuthProfileStore(): AuthProfileStore {
|
||||
return { version: AUTH_STORE_VERSION, profiles: {} };
|
||||
}
|
||||
|
||||
export function ensureAuthProfileStore(): AuthProfileStore {
|
||||
const authPath = resolveAuthStorePath();
|
||||
export function ensureAuthProfileStore(agentDir?: string): AuthProfileStore {
|
||||
const authPath = resolveAuthStorePath(agentDir);
|
||||
const raw = loadJsonFile(authPath);
|
||||
const asStore = coerceAuthStore(raw);
|
||||
if (asStore) return asStore;
|
||||
|
||||
const legacyRaw = loadJsonFile(resolveLegacyAuthStorePath());
|
||||
const legacyRaw = loadJsonFile(resolveLegacyAuthStorePath(agentDir));
|
||||
const legacy = coerceLegacyStore(legacyRaw);
|
||||
const store: AuthProfileStore = {
|
||||
version: AUTH_STORE_VERSION,
|
||||
@@ -307,8 +308,11 @@ export function ensureAuthProfileStore(): AuthProfileStore {
|
||||
return store;
|
||||
}
|
||||
|
||||
export function saveAuthProfileStore(store: AuthProfileStore): void {
|
||||
const authPath = resolveAuthStorePath();
|
||||
export function saveAuthProfileStore(
|
||||
store: AuthProfileStore,
|
||||
agentDir?: string,
|
||||
): void {
|
||||
const authPath = resolveAuthStorePath(agentDir);
|
||||
const payload = {
|
||||
version: AUTH_STORE_VERSION,
|
||||
profiles: store.profiles,
|
||||
@@ -321,10 +325,11 @@ export function saveAuthProfileStore(store: AuthProfileStore): void {
|
||||
export function upsertAuthProfile(params: {
|
||||
profileId: string;
|
||||
credential: AuthProfileCredential;
|
||||
agentDir?: string;
|
||||
}): void {
|
||||
const store = ensureAuthProfileStore();
|
||||
const store = ensureAuthProfileStore(params.agentDir);
|
||||
store.profiles[params.profileId] = params.credential;
|
||||
saveAuthProfileStore(store);
|
||||
saveAuthProfileStore(store, params.agentDir);
|
||||
}
|
||||
|
||||
export function listProfilesForProvider(
|
||||
@@ -354,8 +359,9 @@ export function isProfileInCooldown(
|
||||
export function markAuthProfileUsed(params: {
|
||||
store: AuthProfileStore;
|
||||
profileId: string;
|
||||
agentDir?: string;
|
||||
}): void {
|
||||
const { store, profileId } = params;
|
||||
const { store, profileId, agentDir } = params;
|
||||
if (!store.profiles[profileId]) return;
|
||||
|
||||
store.usageStats = store.usageStats ?? {};
|
||||
@@ -365,7 +371,7 @@ export function markAuthProfileUsed(params: {
|
||||
errorCount: 0,
|
||||
cooldownUntil: undefined,
|
||||
};
|
||||
saveAuthProfileStore(store);
|
||||
saveAuthProfileStore(store, agentDir);
|
||||
}
|
||||
|
||||
export function calculateAuthProfileCooldownMs(errorCount: number): number {
|
||||
@@ -383,8 +389,9 @@ export function calculateAuthProfileCooldownMs(errorCount: number): number {
|
||||
export function markAuthProfileCooldown(params: {
|
||||
store: AuthProfileStore;
|
||||
profileId: string;
|
||||
agentDir?: string;
|
||||
}): void {
|
||||
const { store, profileId } = params;
|
||||
const { store, profileId, agentDir } = params;
|
||||
if (!store.profiles[profileId]) return;
|
||||
|
||||
store.usageStats = store.usageStats ?? {};
|
||||
@@ -399,7 +406,7 @@ export function markAuthProfileCooldown(params: {
|
||||
errorCount,
|
||||
cooldownUntil: Date.now() + backoffMs,
|
||||
};
|
||||
saveAuthProfileStore(store);
|
||||
saveAuthProfileStore(store, agentDir);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -408,8 +415,9 @@ export function markAuthProfileCooldown(params: {
|
||||
export function clearAuthProfileCooldown(params: {
|
||||
store: AuthProfileStore;
|
||||
profileId: string;
|
||||
agentDir?: string;
|
||||
}): void {
|
||||
const { store, profileId } = params;
|
||||
const { store, profileId, agentDir } = params;
|
||||
if (!store.usageStats?.[profileId]) return;
|
||||
|
||||
store.usageStats[profileId] = {
|
||||
@@ -417,7 +425,7 @@ export function clearAuthProfileCooldown(params: {
|
||||
errorCount: 0,
|
||||
cooldownUntil: undefined,
|
||||
};
|
||||
saveAuthProfileStore(store);
|
||||
saveAuthProfileStore(store, agentDir);
|
||||
}
|
||||
|
||||
export function resolveAuthProfileOrder(params: {
|
||||
@@ -527,6 +535,7 @@ export async function resolveApiKeyForProfile(params: {
|
||||
cfg?: ClawdbotConfig;
|
||||
store: AuthProfileStore;
|
||||
profileId: string;
|
||||
agentDir?: string;
|
||||
}): Promise<{ apiKey: string; provider: string; email?: string } | null> {
|
||||
const { cfg, store, profileId } = params;
|
||||
const cred = store.profiles[profileId];
|
||||
@@ -550,6 +559,7 @@ export async function resolveApiKeyForProfile(params: {
|
||||
const result = await refreshOAuthTokenWithLock({
|
||||
profileId,
|
||||
provider: cred.provider,
|
||||
agentDir: params.agentDir,
|
||||
});
|
||||
if (!result) return null;
|
||||
return {
|
||||
@@ -558,7 +568,7 @@ export async function resolveApiKeyForProfile(params: {
|
||||
email: cred.email,
|
||||
};
|
||||
} catch (error) {
|
||||
const refreshedStore = ensureAuthProfileStore();
|
||||
const refreshedStore = ensureAuthProfileStore(params.agentDir);
|
||||
const refreshed = refreshedStore.profiles[profileId];
|
||||
if (refreshed?.type === "oauth" && Date.now() < refreshed.expires) {
|
||||
return {
|
||||
@@ -579,12 +589,13 @@ export function markAuthProfileGood(params: {
|
||||
store: AuthProfileStore;
|
||||
provider: string;
|
||||
profileId: string;
|
||||
agentDir?: string;
|
||||
}): void {
|
||||
const { store, provider, profileId } = params;
|
||||
const { store, provider, profileId, agentDir } = params;
|
||||
const profile = store.profiles[profileId];
|
||||
if (!profile || profile.provider !== provider) return;
|
||||
store.lastGood = { ...store.lastGood, [provider]: profileId };
|
||||
saveAuthProfileStore(store);
|
||||
saveAuthProfileStore(store, agentDir);
|
||||
}
|
||||
|
||||
export function resolveAuthStorePathForDisplay(): string {
|
||||
|
||||
@@ -36,14 +36,14 @@ describe("sessions tools", () => {
|
||||
kind: "direct",
|
||||
sessionId: "s-main",
|
||||
updatedAt: 10,
|
||||
lastChannel: "whatsapp",
|
||||
lastProvider: "whatsapp",
|
||||
},
|
||||
{
|
||||
key: "discord:group:dev",
|
||||
kind: "group",
|
||||
sessionId: "s-group",
|
||||
updatedAt: 11,
|
||||
surface: "discord",
|
||||
provider: "discord",
|
||||
displayName: "discord:g-dev",
|
||||
},
|
||||
{
|
||||
@@ -196,7 +196,7 @@ describe("sessions tools", () => {
|
||||
|
||||
const tool = createClawdbotTools({
|
||||
agentSessionKey: requesterKey,
|
||||
agentSurface: "discord",
|
||||
agentProvider: "discord",
|
||||
}).find((candidate) => candidate.name === "sessions_send");
|
||||
expect(tool).toBeDefined();
|
||||
if (!tool) throw new Error("missing sessions_send tool");
|
||||
@@ -340,7 +340,7 @@ describe("sessions tools", () => {
|
||||
|
||||
const tool = createClawdbotTools({
|
||||
agentSessionKey: requesterKey,
|
||||
agentSurface: "discord",
|
||||
agentProvider: "discord",
|
||||
}).find((candidate) => candidate.name === "sessions_send");
|
||||
expect(tool).toBeDefined();
|
||||
if (!tool) throw new Error("missing sessions_send tool");
|
||||
|
||||
@@ -22,7 +22,7 @@ vi.mock("../config/config.js", async (importOriginal) => {
|
||||
import { createClawdbotTools } from "./clawdbot-tools.js";
|
||||
|
||||
describe("subagents", () => {
|
||||
it("sessions_spawn announces back to the requester group surface", async () => {
|
||||
it("sessions_spawn announces back to the requester group provider", async () => {
|
||||
callGatewayMock.mockReset();
|
||||
const calls: Array<{ method?: string; params?: unknown }> = [];
|
||||
let agentCallCount = 0;
|
||||
@@ -83,7 +83,7 @@ describe("subagents", () => {
|
||||
|
||||
const tool = createClawdbotTools({
|
||||
agentSessionKey: "discord:group:req",
|
||||
agentSurface: "discord",
|
||||
agentProvider: "discord",
|
||||
}).find((candidate) => candidate.name === "sessions_spawn");
|
||||
if (!tool) throw new Error("missing sessions_spawn tool");
|
||||
|
||||
@@ -103,14 +103,14 @@ describe("subagents", () => {
|
||||
| undefined;
|
||||
expect(first?.lane).toBe("subagent");
|
||||
expect(first?.deliver).toBe(false);
|
||||
expect(first?.sessionKey?.startsWith("subagent:")).toBe(true);
|
||||
expect(first?.sessionKey?.startsWith("agent:main:subagent:")).toBe(true);
|
||||
|
||||
expect(sendParams).toMatchObject({
|
||||
provider: "discord",
|
||||
to: "channel:req",
|
||||
message: "announce now",
|
||||
});
|
||||
expect(deletedKey?.startsWith("subagent:")).toBe(true);
|
||||
expect(deletedKey?.startsWith("agent:main:subagent:")).toBe(true);
|
||||
});
|
||||
|
||||
it("sessions_spawn resolves main announce target from sessions.list", async () => {
|
||||
@@ -129,7 +129,7 @@ describe("subagents", () => {
|
||||
sessions: [
|
||||
{
|
||||
key: "main",
|
||||
lastChannel: "whatsapp",
|
||||
lastProvider: "whatsapp",
|
||||
lastTo: "+123",
|
||||
},
|
||||
],
|
||||
@@ -182,7 +182,7 @@ describe("subagents", () => {
|
||||
|
||||
const tool = createClawdbotTools({
|
||||
agentSessionKey: "main",
|
||||
agentSurface: "whatsapp",
|
||||
agentProvider: "whatsapp",
|
||||
}).find((candidate) => candidate.name === "sessions_spawn");
|
||||
if (!tool) throw new Error("missing sessions_spawn tool");
|
||||
|
||||
|
||||
@@ -16,11 +16,15 @@ import { createSlackTool } from "./tools/slack-tool.js";
|
||||
export function createClawdbotTools(options?: {
|
||||
browserControlUrl?: string;
|
||||
agentSessionKey?: string;
|
||||
agentSurface?: string;
|
||||
agentProvider?: string;
|
||||
agentDir?: string;
|
||||
sandboxed?: boolean;
|
||||
config?: ClawdbotConfig;
|
||||
}): AnyAgentTool[] {
|
||||
const imageTool = createImageTool({ config: options?.config });
|
||||
const imageTool = createImageTool({
|
||||
config: options?.config,
|
||||
agentDir: options?.agentDir,
|
||||
});
|
||||
return [
|
||||
createBrowserTool({ defaultControlUrl: options?.browserControlUrl }),
|
||||
createCanvasTool(),
|
||||
@@ -39,12 +43,12 @@ export function createClawdbotTools(options?: {
|
||||
}),
|
||||
createSessionsSendTool({
|
||||
agentSessionKey: options?.agentSessionKey,
|
||||
agentSurface: options?.agentSurface,
|
||||
agentProvider: options?.agentProvider,
|
||||
sandboxed: options?.sandboxed,
|
||||
}),
|
||||
createSessionsSpawnTool({
|
||||
agentSessionKey: options?.agentSessionKey,
|
||||
agentSurface: options?.agentSurface,
|
||||
agentProvider: options?.agentProvider,
|
||||
sandboxed: options?.sandboxed,
|
||||
}),
|
||||
...(imageTool ? [imageTool] : []),
|
||||
|
||||
@@ -31,15 +31,17 @@ export async function resolveApiKeyForProvider(params: {
|
||||
profileId?: string;
|
||||
preferredProfile?: string;
|
||||
store?: AuthProfileStore;
|
||||
agentDir?: string;
|
||||
}): Promise<{ apiKey: string; profileId?: string; source: string }> {
|
||||
const { provider, cfg, profileId, preferredProfile } = params;
|
||||
const store = params.store ?? ensureAuthProfileStore();
|
||||
const store = params.store ?? ensureAuthProfileStore(params.agentDir);
|
||||
|
||||
if (profileId) {
|
||||
const resolved = await resolveApiKeyForProfile({
|
||||
cfg,
|
||||
store,
|
||||
profileId,
|
||||
agentDir: params.agentDir,
|
||||
});
|
||||
if (!resolved) {
|
||||
throw new Error(`No credentials found for profile "${profileId}".`);
|
||||
@@ -63,6 +65,7 @@ export async function resolveApiKeyForProvider(params: {
|
||||
cfg,
|
||||
store,
|
||||
profileId: candidate,
|
||||
agentDir: params.agentDir,
|
||||
});
|
||||
if (resolved) {
|
||||
return {
|
||||
@@ -146,6 +149,7 @@ export async function getApiKeyForModel(params: {
|
||||
profileId?: string;
|
||||
preferredProfile?: string;
|
||||
store?: AuthProfileStore;
|
||||
agentDir?: string;
|
||||
}): Promise<{ apiKey: string; profileId?: string; source: string }> {
|
||||
return resolveApiKeyForProvider({
|
||||
provider: params.model.provider,
|
||||
@@ -153,5 +157,6 @@ export async function getApiKeyForModel(params: {
|
||||
profileId: params.profileId,
|
||||
preferredProfile: params.preferredProfile,
|
||||
store: params.store,
|
||||
agentDir: params.agentDir,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,10 +2,7 @@ import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import { type ClawdbotConfig, loadConfig } from "../config/config.js";
|
||||
import {
|
||||
ensureClawdbotAgentEnv,
|
||||
resolveClawdbotAgentDir,
|
||||
} from "./agent-paths.js";
|
||||
import { resolveClawdbotAgentDir } from "./agent-paths.js";
|
||||
|
||||
type ModelsConfig = NonNullable<ClawdbotConfig["models"]>;
|
||||
|
||||
@@ -26,15 +23,21 @@ async function readJson(pathname: string): Promise<unknown> {
|
||||
|
||||
export async function ensureClawdbotModelsJson(
|
||||
config?: ClawdbotConfig,
|
||||
agentDirOverride?: string,
|
||||
): Promise<{ agentDir: string; wrote: boolean }> {
|
||||
const cfg = config ?? loadConfig();
|
||||
const providers = cfg.models?.providers;
|
||||
if (!providers || Object.keys(providers).length === 0) {
|
||||
return { agentDir: resolveClawdbotAgentDir(), wrote: false };
|
||||
const agentDir = agentDirOverride?.trim()
|
||||
? agentDirOverride.trim()
|
||||
: resolveClawdbotAgentDir();
|
||||
return { agentDir, wrote: false };
|
||||
}
|
||||
|
||||
const mode = cfg.models?.mode ?? DEFAULT_MODE;
|
||||
const agentDir = ensureClawdbotAgentEnv();
|
||||
const agentDir = agentDirOverride?.trim()
|
||||
? agentDirOverride.trim()
|
||||
: resolveClawdbotAgentDir();
|
||||
const targetPath = path.join(agentDir, "models.json");
|
||||
|
||||
let mergedProviders = providers;
|
||||
|
||||
@@ -335,9 +335,10 @@ function resolvePromptSkills(
|
||||
export async function compactEmbeddedPiSession(params: {
|
||||
sessionId: string;
|
||||
sessionKey?: string;
|
||||
surface?: string;
|
||||
messageProvider?: string;
|
||||
sessionFile: string;
|
||||
workspaceDir: string;
|
||||
agentDir?: string;
|
||||
config?: ClawdbotConfig;
|
||||
skillsSnapshot?: SkillSnapshot;
|
||||
provider?: string;
|
||||
@@ -366,7 +367,7 @@ export async function compactEmbeddedPiSession(params: {
|
||||
(params.provider ?? DEFAULT_PROVIDER).trim() || DEFAULT_PROVIDER;
|
||||
const modelId = (params.model ?? DEFAULT_MODEL).trim() || DEFAULT_MODEL;
|
||||
await ensureClawdbotModelsJson(params.config);
|
||||
const agentDir = resolveClawdbotAgentDir();
|
||||
const agentDir = params.agentDir ?? resolveClawdbotAgentDir();
|
||||
const { model, error, authStorage, modelRegistry } = resolveModel(
|
||||
provider,
|
||||
modelId,
|
||||
@@ -440,8 +441,9 @@ export async function compactEmbeddedPiSession(params: {
|
||||
elevated: params.bashElevated,
|
||||
},
|
||||
sandbox,
|
||||
surface: params.surface,
|
||||
messageProvider: params.messageProvider,
|
||||
sessionKey: params.sessionKey ?? params.sessionId,
|
||||
agentDir,
|
||||
config: params.config,
|
||||
});
|
||||
const machineName = await getMachineDisplayName();
|
||||
@@ -544,9 +546,10 @@ export async function compactEmbeddedPiSession(params: {
|
||||
export async function runEmbeddedPiAgent(params: {
|
||||
sessionId: string;
|
||||
sessionKey?: string;
|
||||
surface?: string;
|
||||
messageProvider?: string;
|
||||
sessionFile: string;
|
||||
workspaceDir: string;
|
||||
agentDir?: string;
|
||||
config?: ClawdbotConfig;
|
||||
skillsSnapshot?: SkillSnapshot;
|
||||
prompt: string;
|
||||
@@ -601,7 +604,7 @@ export async function runEmbeddedPiAgent(params: {
|
||||
(params.provider ?? DEFAULT_PROVIDER).trim() || DEFAULT_PROVIDER;
|
||||
const modelId = (params.model ?? DEFAULT_MODEL).trim() || DEFAULT_MODEL;
|
||||
await ensureClawdbotModelsJson(params.config);
|
||||
const agentDir = resolveClawdbotAgentDir();
|
||||
const agentDir = params.agentDir ?? resolveClawdbotAgentDir();
|
||||
const { model, error, authStorage, modelRegistry } = resolveModel(
|
||||
provider,
|
||||
modelId,
|
||||
@@ -610,7 +613,7 @@ export async function runEmbeddedPiAgent(params: {
|
||||
if (!model) {
|
||||
throw new Error(error ?? `Unknown model: ${provider}/${modelId}`);
|
||||
}
|
||||
const authStore = ensureAuthProfileStore();
|
||||
const authStore = ensureAuthProfileStore(agentDir);
|
||||
const explicitProfileId = params.authProfileId?.trim();
|
||||
const profileOrder = resolveAuthProfileOrder({
|
||||
cfg: params.config,
|
||||
@@ -678,7 +681,7 @@ export async function runEmbeddedPiAgent(params: {
|
||||
attemptedThinking.add(thinkLevel);
|
||||
|
||||
log.debug(
|
||||
`embedded run start: runId=${params.runId} sessionId=${params.sessionId} provider=${provider} model=${modelId} thinking=${thinkLevel} surface=${params.surface ?? "unknown"}`,
|
||||
`embedded run start: runId=${params.runId} sessionId=${params.sessionId} provider=${provider} model=${modelId} thinking=${thinkLevel} messageProvider=${params.messageProvider ?? "unknown"}`,
|
||||
);
|
||||
|
||||
await fs.mkdir(resolvedWorkspace, { recursive: true });
|
||||
@@ -734,8 +737,9 @@ export async function runEmbeddedPiAgent(params: {
|
||||
elevated: params.bashElevated,
|
||||
},
|
||||
sandbox,
|
||||
surface: params.surface,
|
||||
messageProvider: params.messageProvider,
|
||||
sessionKey: params.sessionKey ?? params.sessionId,
|
||||
agentDir,
|
||||
config: params.config,
|
||||
});
|
||||
const machineName = await getMachineDisplayName();
|
||||
|
||||
@@ -100,24 +100,26 @@ describe("createClawdbotCodingTools", () => {
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it("scopes discord tool to discord surface", () => {
|
||||
const other = createClawdbotCodingTools({ surface: "whatsapp" });
|
||||
it("scopes discord tool to discord provider", () => {
|
||||
const other = createClawdbotCodingTools({ messageProvider: "whatsapp" });
|
||||
expect(other.some((tool) => tool.name === "discord")).toBe(false);
|
||||
|
||||
const discord = createClawdbotCodingTools({ surface: "discord" });
|
||||
const discord = createClawdbotCodingTools({ messageProvider: "discord" });
|
||||
expect(discord.some((tool) => tool.name === "discord")).toBe(true);
|
||||
});
|
||||
|
||||
it("scopes slack tool to slack surface", () => {
|
||||
const other = createClawdbotCodingTools({ surface: "whatsapp" });
|
||||
it("scopes slack tool to slack provider", () => {
|
||||
const other = createClawdbotCodingTools({ messageProvider: "whatsapp" });
|
||||
expect(other.some((tool) => tool.name === "slack")).toBe(false);
|
||||
|
||||
const slack = createClawdbotCodingTools({ surface: "slack" });
|
||||
const slack = createClawdbotCodingTools({ messageProvider: "slack" });
|
||||
expect(slack.some((tool) => tool.name === "slack")).toBe(true);
|
||||
});
|
||||
|
||||
it("filters session tools for sub-agent sessions by default", () => {
|
||||
const tools = createClawdbotCodingTools({ sessionKey: "subagent:test" });
|
||||
const tools = createClawdbotCodingTools({
|
||||
sessionKey: "agent:main:subagent:test",
|
||||
});
|
||||
const names = new Set(tools.map((tool) => tool.name));
|
||||
expect(names.has("sessions_list")).toBe(false);
|
||||
expect(names.has("sessions_history")).toBe(false);
|
||||
@@ -131,7 +133,7 @@ describe("createClawdbotCodingTools", () => {
|
||||
|
||||
it("supports allow-only sub-agent tool policy", () => {
|
||||
const tools = createClawdbotCodingTools({
|
||||
sessionKey: "subagent:test",
|
||||
sessionKey: "agent:main:subagent:test",
|
||||
// Intentionally partial config; only fields used by pi-tools are provided.
|
||||
config: {
|
||||
agent: {
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { detectMime } from "../media/mime.js";
|
||||
import { isSubagentSessionKey } from "../routing/session-key.js";
|
||||
import { startWebLoginWithQr, waitForWebLogin } from "../web/login-qr.js";
|
||||
import {
|
||||
type BashToolDefaults,
|
||||
@@ -340,11 +341,6 @@ const DEFAULT_SUBAGENT_TOOL_DENY = [
|
||||
"sessions_spawn",
|
||||
];
|
||||
|
||||
function isSubagentSessionKey(sessionKey?: string): boolean {
|
||||
const key = sessionKey?.trim().toLowerCase() ?? "";
|
||||
return key.startsWith("subagent:");
|
||||
}
|
||||
|
||||
function resolveSubagentToolPolicy(cfg?: ClawdbotConfig): SandboxToolPolicy {
|
||||
const configured = cfg?.agent?.subagents?.tools;
|
||||
const deny = [
|
||||
@@ -488,28 +484,31 @@ function createClawdbotReadTool(base: AnyAgentTool): AnyAgentTool {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSurface(surface?: string): string | undefined {
|
||||
const trimmed = surface?.trim().toLowerCase();
|
||||
function normalizeMessageProvider(
|
||||
messageProvider?: string,
|
||||
): string | undefined {
|
||||
const trimmed = messageProvider?.trim().toLowerCase();
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function shouldIncludeDiscordTool(surface?: string): boolean {
|
||||
const normalized = normalizeSurface(surface);
|
||||
function shouldIncludeDiscordTool(messageProvider?: string): boolean {
|
||||
const normalized = normalizeMessageProvider(messageProvider);
|
||||
if (!normalized) return false;
|
||||
return normalized === "discord" || normalized.startsWith("discord:");
|
||||
}
|
||||
|
||||
function shouldIncludeSlackTool(surface?: string): boolean {
|
||||
const normalized = normalizeSurface(surface);
|
||||
function shouldIncludeSlackTool(messageProvider?: string): boolean {
|
||||
const normalized = normalizeMessageProvider(messageProvider);
|
||||
if (!normalized) return false;
|
||||
return normalized === "slack" || normalized.startsWith("slack:");
|
||||
}
|
||||
|
||||
export function createClawdbotCodingTools(options?: {
|
||||
bash?: BashToolDefaults & ProcessToolDefaults;
|
||||
surface?: string;
|
||||
messageProvider?: string;
|
||||
sandbox?: SandboxContext | null;
|
||||
sessionKey?: string;
|
||||
agentDir?: string;
|
||||
config?: ClawdbotConfig;
|
||||
}): AnyAgentTool[] {
|
||||
const bashToolName = "bash";
|
||||
@@ -555,13 +554,14 @@ export function createClawdbotCodingTools(options?: {
|
||||
...createClawdbotTools({
|
||||
browserControlUrl: sandbox?.browser?.controlUrl,
|
||||
agentSessionKey: options?.sessionKey,
|
||||
agentSurface: options?.surface,
|
||||
agentProvider: options?.messageProvider,
|
||||
agentDir: options?.agentDir,
|
||||
sandboxed: !!sandbox,
|
||||
config: options?.config,
|
||||
}),
|
||||
];
|
||||
const allowDiscord = shouldIncludeDiscordTool(options?.surface);
|
||||
const allowSlack = shouldIncludeSlackTool(options?.surface);
|
||||
const allowDiscord = shouldIncludeDiscordTool(options?.messageProvider);
|
||||
const allowSlack = shouldIncludeSlackTool(options?.messageProvider);
|
||||
const filtered = tools.filter((tool) => {
|
||||
if (tool.name === "discord") return allowDiscord;
|
||||
if (tool.name === "slack") return allowSlack;
|
||||
|
||||
@@ -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,
|
||||
|
||||
52
src/agents/tools/sessions-announce-target.test.ts
Normal file
52
src/agents/tools/sessions-announce-target.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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))
|
||||
|
||||
43
src/agents/tools/sessions-list-tool.gating.test.ts
Normal file
43
src/agents/tools/sessions-list-tool.gating.test.ts
Normal 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" }],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
43
src/agents/tools/sessions-send-tool.gating.test.ts
Normal file
43
src/agents/tools/sessions-send-tool.gating.test.ts
Normal 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" });
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user