feat: add agents_list tool

This commit is contained in:
Peter Steinberger
2026-01-08 07:06:36 +00:00
parent 0ba72477de
commit 2b29b86ab5
10 changed files with 318 additions and 2 deletions

View File

@@ -80,6 +80,7 @@
- Sub-agents: allow `sessions_spawn` model overrides and error on invalid models. Thanks @azade-c for PR #298.
- Sub-agents: skip invalid model overrides with a warning and keep the run alive; tool exceptions now return tool errors instead of crashing the agent.
- Sub-agents: allow `sessions_spawn` to target other agents via per-agent allowlists (`routing.agents.<agentId>.subagents.allowAgents`).
- Tools: add `agents_list` to reveal allowed `sessions_spawn` targets for the current agent.
- Sessions: forward explicit sessionKey through gateway/chat/node bridge to avoid sub-agent sessionId mixups.
- Heartbeat: default interval 30m; clarified default prompt usage and HEARTBEAT.md template behavior.
- Onboarding: write auth profiles to the multi-agent path (`~/.clawdbot/agents/main/agent/`) so the gateway finds credentials on first startup. Thanks @minghinmatthewlam for PR #327.

View File

@@ -134,6 +134,9 @@ Parameters:
Allowlist:
- `routing.agents.<agentId>.subagents.allowAgents`: list of agent ids allowed via `agentId` (`["*"]` to allow any). Default: only the requester agent.
Discovery:
- Use `agents_list` to discover which agent ids are allowed for `sessions_spawn`.
Behavior:
- Starts a new `agent:<agentId>:subagent:<uuid>` session with `deliver: false`.
- Sub-agents default to the full tool set **minus session tools** (configurable via `agent.subagents.tools`).

View File

@@ -188,6 +188,13 @@ Notes:
- `sessions_send` runs a replyback pingpong (reply `REPLY_SKIP` to stop; max turns via `session.agentToAgent.maxPingPongTurns`, 05).
- After the pingpong, the target agent runs an **announce step**; reply `ANNOUNCE_SKIP` to suppress the announcement.
### `agents_list`
List agent ids that the current session may target with `sessions_spawn`.
Notes:
- Result is restricted to per-agent allowlists (`routing.agents.<agentId>.subagents.allowAgents`).
- When `["*"]` is configured, the tool includes all configured agents and marks `allowAny: true`.
### `discord`
Send Discord reactions, stickers, or polls.

View File

@@ -32,6 +32,9 @@ Tool params:
Allowlist:
- `routing.agents.<agentId>.subagents.allowAgents`: list of agent ids that can be targeted via `agentId` (`["*"]` to allow any). Default: only the requester agent.
Discovery:
- Use `agents_list` to see which agent ids are currently allowed for `sessions_spawn`.
Auto-archive:
- Sub-agent sessions are automatically archived after `agent.subagents.archiveAfterMinutes` (default: 60).
- Archive uses `sessions.delete` and renames the transcript to `*.deleted.<timestamp>` (same folder).

View File

@@ -0,0 +1,123 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
let configOverride: ReturnType<
typeof import("../config/config.js")["loadConfig"]
> = {
session: {
mainKey: "main",
scope: "per-sender",
},
};
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
...actual,
loadConfig: () => configOverride,
resolveGatewayPort: () => 18789,
};
});
import { createClawdbotTools } from "./clawdbot-tools.js";
describe("agents_list", () => {
beforeEach(() => {
configOverride = {
session: {
mainKey: "main",
scope: "per-sender",
},
};
});
it("defaults to the requester agent only", async () => {
const tool = createClawdbotTools({
agentSessionKey: "main",
}).find((candidate) => candidate.name === "agents_list");
if (!tool) throw new Error("missing agents_list tool");
const result = await tool.execute("call1", {});
expect(result.details).toMatchObject({
requester: "main",
allowAny: false,
});
const agents = (result.details as { agents?: Array<{ id: string }> })
.agents;
expect(agents?.map((agent) => agent.id)).toEqual(["main"]);
});
it("includes allowlisted targets plus requester", async () => {
configOverride = {
session: {
mainKey: "main",
scope: "per-sender",
},
routing: {
agents: {
main: {
name: "Main",
subagents: {
allowAgents: ["research"],
},
},
research: {
name: "Research",
},
},
},
};
const tool = createClawdbotTools({
agentSessionKey: "main",
}).find((candidate) => candidate.name === "agents_list");
if (!tool) throw new Error("missing agents_list tool");
const result = await tool.execute("call2", {});
const agents = (result.details as {
agents?: Array<{ id: string }>;
}).agents;
expect(agents?.map((agent) => agent.id)).toEqual(["main", "research"]);
});
it("returns configured agents when allowlist is *", async () => {
configOverride = {
session: {
mainKey: "main",
scope: "per-sender",
},
routing: {
agents: {
main: {
subagents: {
allowAgents: ["*"],
},
},
research: {
name: "Research",
},
coder: {
name: "Coder",
},
},
},
};
const tool = createClawdbotTools({
agentSessionKey: "main",
}).find((candidate) => candidate.name === "agents_list");
if (!tool) throw new Error("missing agents_list tool");
const result = await tool.execute("call3", {});
expect(result.details).toMatchObject({
allowAny: true,
});
const agents = (result.details as {
agents?: Array<{ id: string }>;
}).agents;
expect(agents?.map((agent) => agent.id)).toEqual([
"main",
"coder",
"research",
]);
});
});

View File

@@ -286,6 +286,26 @@ describe("subagents", () => {
expect(childSessionKey?.startsWith("agent:main:subagent:")).toBe(true);
});
it("sessions_spawn only allows same-agent by default", async () => {
resetSubagentRegistryForTests();
callGatewayMock.mockReset();
const tool = createClawdbotTools({
agentSessionKey: "main",
agentProvider: "whatsapp",
}).find((candidate) => candidate.name === "sessions_spawn");
if (!tool) throw new Error("missing sessions_spawn tool");
const result = await tool.execute("call6", {
task: "do thing",
agentId: "beta",
});
expect(result.details).toMatchObject({
status: "forbidden",
});
expect(callGatewayMock).not.toHaveBeenCalled();
});
it("sessions_spawn allows cross-agent spawning when configured", async () => {
resetSubagentRegistryForTests();
callGatewayMock.mockReset();
@@ -325,7 +345,58 @@ describe("subagents", () => {
}).find((candidate) => candidate.name === "sessions_spawn");
if (!tool) throw new Error("missing sessions_spawn tool");
const result = await tool.execute("call6", {
const result = await tool.execute("call7", {
task: "do thing",
agentId: "beta",
});
expect(result.details).toMatchObject({
status: "accepted",
runId: "run-1",
});
expect(childSessionKey?.startsWith("agent:beta:subagent:")).toBe(true);
});
it("sessions_spawn allows any agent when allowlist is *", async () => {
resetSubagentRegistryForTests();
callGatewayMock.mockReset();
configOverride = {
session: {
mainKey: "main",
scope: "per-sender",
},
routing: {
agents: {
main: {
subagents: {
allowAgents: ["*"],
},
},
},
},
};
let childSessionKey: string | undefined;
callGatewayMock.mockImplementation(async (opts: unknown) => {
const request = opts as { method?: string; params?: unknown };
if (request.method === "agent") {
const params = request.params as { sessionKey?: string } | undefined;
childSessionKey = params?.sessionKey;
return { runId: "run-1", status: "accepted", acceptedAt: 5100 };
}
if (request.method === "agent.wait") {
return { status: "timeout" };
}
return {};
});
const tool = createClawdbotTools({
agentSessionKey: "main",
agentProvider: "whatsapp",
}).find((candidate) => candidate.name === "sessions_spawn");
if (!tool) throw new Error("missing sessions_spawn tool");
const result = await tool.execute("call8", {
task: "do thing",
agentId: "beta",
});
@@ -362,7 +433,7 @@ describe("subagents", () => {
}).find((candidate) => candidate.name === "sessions_spawn");
if (!tool) throw new Error("missing sessions_spawn tool");
const result = await tool.execute("call7", {
const result = await tool.execute("call9", {
task: "do thing",
agentId: "beta",
});

View File

@@ -1,6 +1,7 @@
import type { ClawdbotConfig } from "../config/config.js";
import { createBrowserTool } from "./tools/browser-tool.js";
import { createCanvasTool } from "./tools/canvas-tool.js";
import { createAgentsListTool } from "./tools/agents-list-tool.js";
import type { AnyAgentTool } from "./tools/common.js";
import { createCronTool } from "./tools/cron-tool.js";
import { createDiscordTool } from "./tools/discord-tool.js";
@@ -37,6 +38,7 @@ export function createClawdbotTools(options?: {
createTelegramTool(),
createWhatsAppTool(),
createGatewayTool({ agentSessionKey: options?.agentSessionKey }),
createAgentsListTool({ agentSessionKey: options?.agentSessionKey }),
createSessionsListTool({
agentSessionKey: options?.agentSessionKey,
sandboxed: options?.sandboxed,

View File

@@ -45,6 +45,7 @@ export function buildAgentSystemPrompt(params: {
cron: "Manage cron jobs and wake events",
gateway:
"Restart, apply config, or run updates on the running ClaudeBot process",
agents_list: "List agent ids allowed for sessions_spawn",
sessions_list: "List other sessions (incl. sub-agents) with filters/last",
sessions_history: "Fetch history for another session/sub-agent",
sessions_send: "Send a message to another session/sub-agent",
@@ -71,6 +72,7 @@ export function buildAgentSystemPrompt(params: {
"nodes",
"cron",
"gateway",
"agents_list",
"sessions_list",
"sessions_history",
"sessions_send",

View File

@@ -150,6 +150,11 @@
"restart": { "label": "restart", "detailKeys": ["reason", "delayMs"] }
}
},
"agents_list": {
"emoji": "🧭",
"title": "Agents",
"detailKeys": []
},
"sessions_list": {
"emoji": "🗂️",
"title": "Sessions",

View File

@@ -0,0 +1,99 @@
import { Type } from "@sinclair/typebox";
import { loadConfig } from "../../config/config.js";
import {
DEFAULT_AGENT_ID,
normalizeAgentId,
parseAgentSessionKey,
} from "../../routing/session-key.js";
import { resolveAgentConfig } from "../agent-scope.js";
import type { AnyAgentTool } from "./common.js";
import { jsonResult } from "./common.js";
import {
resolveInternalSessionKey,
resolveMainSessionAlias,
} from "./sessions-helpers.js";
const AgentsListToolSchema = Type.Object({});
type AgentListEntry = {
id: string;
name?: string;
configured: boolean;
};
export function createAgentsListTool(opts?: {
agentSessionKey?: string;
}): AnyAgentTool {
return {
label: "Agents",
name: "agents_list",
description:
"List agent ids you can target with sessions_spawn (based on allowlists).",
parameters: AgentsListToolSchema,
execute: async () => {
const cfg = loadConfig();
const { mainKey, alias } = resolveMainSessionAlias(cfg);
const requesterInternalKey =
typeof opts?.agentSessionKey === "string" && opts.agentSessionKey.trim()
? resolveInternalSessionKey({
key: opts.agentSessionKey,
alias,
mainKey,
})
: alias;
const requesterAgentId = normalizeAgentId(
parseAgentSessionKey(requesterInternalKey)?.agentId ?? DEFAULT_AGENT_ID,
);
const allowAgents =
resolveAgentConfig(cfg, requesterAgentId)?.subagents?.allowAgents ?? [];
const allowAny = allowAgents.some((value) => value.trim() === "*");
const allowSet = new Set(
allowAgents
.filter((value) => value.trim() && value.trim() !== "*")
.map((value) => normalizeAgentId(value)),
);
const configuredAgents = cfg.routing?.agents ?? {};
const configuredIds = Object.keys(configuredAgents).map((key) =>
normalizeAgentId(key),
);
const configuredNameMap = new Map<string, string>();
for (const [key, value] of Object.entries(configuredAgents)) {
if (!value || typeof value !== "object") continue;
const name =
typeof (value as { name?: unknown }).name === "string"
? ((value as { name?: string }).name?.trim() ?? "")
: "";
if (!name) continue;
configuredNameMap.set(normalizeAgentId(key), name);
}
const allowed = new Set<string>();
allowed.add(requesterAgentId);
if (allowAny) {
for (const id of configuredIds) allowed.add(id);
} else {
for (const id of allowSet) allowed.add(id);
}
const all = Array.from(allowed);
const rest = all
.filter((id) => id !== requesterAgentId)
.sort((a, b) => a.localeCompare(b));
const ordered = [requesterAgentId, ...rest];
const agents: AgentListEntry[] = ordered.map((id) => ({
id,
name: configuredNameMap.get(id),
configured: configuredIds.includes(id),
}));
return jsonResult({
requester: requesterAgentId,
allowAny,
agents,
});
},
};
}