fix: land broadcast groups (#547) (thanks @pasogott)

This commit is contained in:
Peter Steinberger
2026-01-09 21:14:19 +01:00
parent 09769d127f
commit 76964162c7
6 changed files with 169 additions and 108 deletions

View File

@@ -27,6 +27,7 @@
- Hooks: default hook agent delivery to true. (#533) — thanks @mcinteerj - Hooks: default hook agent delivery to true. (#533) — thanks @mcinteerj
- Hooks: normalize hook agent providers (aliases + msteams support). - Hooks: normalize hook agent providers (aliases + msteams support).
- WhatsApp: route queued replies to the original sender instead of the bot's own number. (#534) — thanks @mcinteerj - WhatsApp: route queued replies to the original sender instead of the bot's own number. (#534) — thanks @mcinteerj
- WhatsApp: add broadcast groups for multi-agent replies. (#547) — thanks @pasogott
- iMessage: isolate group-ish threads by chat_id. (#535) — thanks @mdahmann - iMessage: isolate group-ish threads by chat_id. (#535) — thanks @mdahmann
- Models: add OAuth expiry checks in doctor, expanded `models status` auth output (missing auth + `--check` exit codes). (#538) — thanks @latitudeki5223 - Models: add OAuth expiry checks in doctor, expanded `models status` auth output (missing auth + `--check` exit codes). (#538) — thanks @latitudeki5223
- Deps: bump Pi to 0.40.0 and drop pi-ai patch (upstream 429 fix). (#543) — thanks @mcinteerj - Deps: bump Pi to 0.40.0 and drop pi-ai patch (upstream 429 fix). (#543) — thanks @mcinteerj

View File

@@ -7,6 +7,8 @@
Broadcast Groups enable multiple agents to process and respond to the same message simultaneously. This allows you to create specialized agent teams that work together in a single WhatsApp group, DM, or channel - all using one phone number. Broadcast Groups enable multiple agents to process and respond to the same message simultaneously. This allows you to create specialized agent teams that work together in a single WhatsApp group, DM, or channel - all using one phone number.
Broadcast groups are evaluated after provider allowlists and group activation rules. In WhatsApp groups, this means broadcasts happen when Clawdbot would normally reply (for example: on mention, depending on your group settings).
## Use Cases ## Use Cases
### 1. Specialized Agent Teams ### 1. Specialized Agent Teams
@@ -52,19 +54,17 @@ Agents:
### Basic Setup ### Basic Setup
Add a `broadcast` section to your routing config: Add a top-level `broadcast` section (next to `bindings`):
```json ```json
{ {
"routing": {
"broadcast": { "broadcast": {
"120363403215116621@g.us": ["alfred", "baerbel", "assistant3"] "120363403215116621@g.us": ["alfred", "baerbel", "assistant3"]
} }
}
} }
``` ```
**Result:** All three agents process every message in this WhatsApp group. **Result:** When Clawdbot would reply in this chat, it will run all three agents.
### Processing Strategy ### Processing Strategy
@@ -74,12 +74,10 @@ Control how agents process messages:
All agents process simultaneously: All agents process simultaneously:
```json ```json
{ {
"routing": {
"broadcast": { "broadcast": {
"strategy": "parallel", "strategy": "parallel",
"120363403215116621@g.us": ["alfred", "baerbel"] "120363403215116621@g.us": ["alfred", "baerbel"]
} }
}
} }
``` ```
@@ -87,12 +85,10 @@ All agents process simultaneously:
Agents process in order (one waits for previous to finish): Agents process in order (one waits for previous to finish):
```json ```json
{ {
"routing": {
"broadcast": { "broadcast": {
"strategy": "sequential", "strategy": "sequential",
"120363403215116621@g.us": ["alfred", "baerbel"] "120363403215116621@g.us": ["alfred", "baerbel"]
} }
}
} }
``` ```
@@ -100,31 +96,33 @@ Agents process in order (one waits for previous to finish):
```json ```json
{ {
"routing": { "agents": {
"defaultAgentId": "main", "list": [
{
"id": "code-reviewer",
"name": "Code Reviewer",
"workspace": "/path/to/code-reviewer",
"sandbox": { "mode": "all" }
},
{
"id": "security-auditor",
"name": "Security Auditor",
"workspace": "/path/to/security-auditor",
"sandbox": { "mode": "all" }
},
{
"id": "docs-generator",
"name": "Documentation Generator",
"workspace": "/path/to/docs-generator",
"sandbox": { "mode": "all" }
}
]
},
"broadcast": { "broadcast": {
"strategy": "parallel", "strategy": "parallel",
"120363403215116621@g.us": ["code-reviewer", "security-auditor", "docs-generator"], "120363403215116621@g.us": ["code-reviewer", "security-auditor", "docs-generator"],
"120363424282127706@g.us": ["support-en", "support-de"], "120363424282127706@g.us": ["support-en", "support-de"],
"+15555550123": ["assistant", "logger"] "+15555550123": ["assistant", "logger"]
},
"agents": {
"code-reviewer": {
"name": "Code Reviewer",
"workspace": "/path/to/code-reviewer",
"sandbox": { "mode": "all" }
},
"security-auditor": {
"name": "Security Auditor",
"workspace": "/path/to/security-auditor",
"sandbox": { "mode": "all" }
},
"docs-generator": {
"name": "Documentation Generator",
"workspace": "/path/to/docs-generator",
"sandbox": { "mode": "all" }
}
}
} }
} }
``` ```
@@ -134,7 +132,7 @@ Agents process in order (one waits for previous to finish):
### Message Flow ### Message Flow
1. **Incoming message** arrives in a WhatsApp group 1. **Incoming message** arrives in a WhatsApp group
2. **Routing check**: System checks if peer ID is in `routing.broadcast` 2. **Broadcast check**: System checks if peer ID is in `broadcast`
3. **If in broadcast list**: 3. **If in broadcast list**:
- All listed agents process the message - All listed agents process the message
- Each agent has its own session key and isolated context - Each agent has its own session key and isolated context
@@ -142,6 +140,8 @@ Agents process in order (one waits for previous to finish):
4. **If not in broadcast list**: 4. **If not in broadcast list**:
- Normal routing applies (first matching binding) - Normal routing applies (first matching binding)
Note: broadcast groups do not bypass provider allowlists or group activation rules (mentions/commands/etc). They only change *which agents run* when a message is eligible for processing.
### Session Isolation ### Session Isolation
Each agent in a broadcast group maintains completely separate: Each agent in a broadcast group maintains completely separate:
@@ -151,6 +151,7 @@ Each agent in a broadcast group maintains completely separate:
- **Workspace** (separate sandboxes if configured) - **Workspace** (separate sandboxes if configured)
- **Tool access** (different allow/deny lists) - **Tool access** (different allow/deny lists)
- **Memory/context** (separate IDENTITY.md, SOUL.md, etc.) - **Memory/context** (separate IDENTITY.md, SOUL.md, etc.)
- **Group context buffer** (recent group messages used for context) is shared per peer, so all broadcast agents see the same context when triggered
This allows each agent to have: This allows each agent to have:
- Different personalities - Different personalities
@@ -258,14 +259,12 @@ Broadcast groups work alongside existing routing:
```json ```json
{ {
"routing": {
"bindings": [ "bindings": [
{ "agentId": "alfred", "match": { "peer": { "id": "GROUP_A" } } } { "match": { "provider": "whatsapp", "peer": { "kind": "group", "id": "GROUP_A" } }, "agentId": "alfred" }
], ],
"broadcast": { "broadcast": {
"GROUP_B": ["agent1", "agent2"] "GROUP_B": ["agent1", "agent2"]
} }
}
} }
``` ```
@@ -279,7 +278,7 @@ Broadcast groups work alongside existing routing:
### Agents Not Responding ### Agents Not Responding
**Check:** **Check:**
1. Agent IDs exist in `routing.agents` 1. Agent IDs exist in `agents.list`
2. Peer ID format is correct (e.g., `120363403215116621@g.us`) 2. Peer ID format is correct (e.g., `120363403215116621@g.us`)
3. Agents are not in deny lists 3. Agents are not in deny lists
@@ -307,7 +306,6 @@ tail -f ~/.clawdbot/logs/gateway.log | grep broadcast
```json ```json
{ {
"routing": {
"broadcast": { "broadcast": {
"strategy": "parallel", "strategy": "parallel",
"120363403215116621@g.us": [ "120363403215116621@g.us": [
@@ -318,23 +316,12 @@ tail -f ~/.clawdbot/logs/gateway.log | grep broadcast
] ]
}, },
"agents": { "agents": {
"code-formatter": { "list": [
"workspace": "~/agents/formatter", { "id": "code-formatter", "workspace": "~/agents/formatter", "tools": { "allow": ["read", "write"] } },
"tools": { "allow": ["read", "write"] } { "id": "security-scanner", "workspace": "~/agents/security", "tools": { "allow": ["read", "bash"] } },
}, { "id": "test-coverage", "workspace": "~/agents/testing", "tools": { "allow": ["read", "bash"] } },
"security-scanner": { { "id": "docs-checker", "workspace": "~/agents/docs", "tools": { "allow": ["read"] } }
"workspace": "~/agents/security", ]
"tools": { "allow": ["read", "bash"] }
},
"test-coverage": {
"workspace": "~/agents/testing",
"tools": { "allow": ["read", "bash"] }
},
"docs-checker": {
"workspace": "~/agents/docs",
"tools": { "allow": ["read"] }
}
}
} }
} }
``` ```
@@ -350,22 +337,16 @@ tail -f ~/.clawdbot/logs/gateway.log | grep broadcast
```json ```json
{ {
"routing": {
"broadcast": { "broadcast": {
"strategy": "sequential", "strategy": "sequential",
"+15555550123": ["detect-language", "translator-en", "translator-de"] "+15555550123": ["detect-language", "translator-en", "translator-de"]
}, },
"agents": { "agents": {
"detect-language": { "list": [
"workspace": "~/agents/lang-detect" { "id": "detect-language", "workspace": "~/agents/lang-detect" },
}, { "id": "translator-en", "workspace": "~/agents/translate-en" },
"translator-en": { { "id": "translator-de", "workspace": "~/agents/translate-de" }
"workspace": "~/agents/translate-en" ]
},
"translator-de": {
"workspace": "~/agents/translate-de"
}
}
} }
} }
``` ```
@@ -375,10 +356,10 @@ tail -f ~/.clawdbot/logs/gateway.log | grep broadcast
### Config Schema ### Config Schema
```typescript ```typescript
interface RoutingConfig { interface ClawdbotConfig {
broadcast?: { broadcast?: {
strategy?: "parallel" | "sequential"; strategy?: "parallel" | "sequential";
[peerId: string]: string[] | "parallel" | "sequential"; [peerId: string]: string[];
}; };
} }
``` ```
@@ -409,6 +390,6 @@ Planned features:
## See Also ## See Also
- [Multi-Agent Configuration](multi-agent-sandbox-tools.md) - [Multi-Agent Configuration](/multi-agent-sandbox-tools)
- [Routing Configuration](routing.md) - [Routing Configuration](/concepts/provider-routing)
- [Session Management](sessions.md) - [Session Management](/concepts/sessions)

View File

@@ -808,6 +808,41 @@ describe("talk.voiceAliases", () => {
}); });
}); });
describe("broadcast", () => {
it("accepts a broadcast peer map with strategy", async () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({
agents: {
list: [{ id: "alfred" }, { id: "baerbel" }],
},
broadcast: {
strategy: "parallel",
"120363403215116621@g.us": ["alfred", "baerbel"],
},
});
expect(res.ok).toBe(true);
});
it("rejects invalid broadcast strategy", async () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({
broadcast: { strategy: "nope" },
});
expect(res.ok).toBe(false);
});
it("rejects non-array broadcast entries", async () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({
broadcast: { "120363403215116621@g.us": 123 },
});
expect(res.ok).toBe(false);
});
});
describe("legacy config detection", () => { describe("legacy config detection", () => {
it("rejects routing.allowFrom", async () => { it("rejects routing.allowFrom", async () => {
vi.resetModules(); vi.resetModules();

View File

@@ -946,6 +946,19 @@ export type AgentBinding = {
}; };
}; };
export type BroadcastStrategy = "parallel" | "sequential";
export type BroadcastConfig = {
/** Default processing strategy for broadcast peers. */
strategy?: BroadcastStrategy;
/**
* Map peer IDs to arrays of agent IDs that should ALL process messages.
*
* Note: the index signature includes `undefined` so `strategy?: ...` remains type-safe.
*/
[peerId: string]: string[] | BroadcastStrategy | undefined;
};
export type AudioConfig = { export type AudioConfig = {
transcription?: { transcription?: {
// Optional CLI to turn inbound audio into text; templated args, must output transcript to stdout. // Optional CLI to turn inbound audio into text; templated args, must output transcript to stdout.
@@ -1373,6 +1386,7 @@ export type ClawdbotConfig = {
agents?: AgentsConfig; agents?: AgentsConfig;
tools?: ToolsConfig; tools?: ToolsConfig;
bindings?: AgentBinding[]; bindings?: AgentBinding[];
broadcast?: BroadcastConfig;
audio?: AudioConfig; audio?: AudioConfig;
messages?: MessagesConfig; messages?: MessagesConfig;
commands?: CommandsConfig; commands?: CommandsConfig;

View File

@@ -842,6 +842,15 @@ const BindingsSchema = z
) )
.optional(); .optional();
const BroadcastStrategySchema = z.enum(["parallel", "sequential"]);
const BroadcastSchema = z
.object({
strategy: BroadcastStrategySchema.optional(),
})
.catchall(z.array(z.string()))
.optional();
const AudioSchema = z const AudioSchema = z
.object({ .object({
transcription: TranscribeAudioSchema, transcription: TranscribeAudioSchema,
@@ -1188,6 +1197,7 @@ export const ClawdbotSchema = z.object({
agents: AgentsSchema, agents: AgentsSchema,
tools: ToolsSchema, tools: ToolsSchema,
bindings: BindingsSchema, bindings: BindingsSchema,
broadcast: BroadcastSchema,
audio: AudioSchema, audio: AudioSchema,
messages: MessagesSchema, messages: MessagesSchema,
commands: CommandsSchema, commands: CommandsSchema,

View File

@@ -48,13 +48,14 @@ import { enqueueSystemEvent } from "../infra/system-events.js";
import { registerUnhandledRejectionHandler } from "../infra/unhandled-rejections.js"; import { registerUnhandledRejectionHandler } from "../infra/unhandled-rejections.js";
import { createSubsystemLogger, getChildLogger } from "../logging.js"; import { createSubsystemLogger, getChildLogger } from "../logging.js";
import { toLocationContext } from "../providers/location.js"; import { toLocationContext } from "../providers/location.js";
import { resolveAgentRoute } from "../routing/resolve-route.js"; import {
buildAgentSessionKey,
resolveAgentRoute,
} from "../routing/resolve-route.js";
import { import {
buildAgentMainSessionKey, buildAgentMainSessionKey,
buildAgentPeerSessionKey,
DEFAULT_MAIN_KEY, DEFAULT_MAIN_KEY,
normalizeAgentId, normalizeAgentId,
normalizeId,
} from "../routing/session-key.js"; } from "../routing/session-key.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import { isSelfChatMode, jidToE164, normalizeE164 } from "../utils.js"; import { isSelfChatMode, jidToE164, normalizeE164 } from "../utils.js";
@@ -1077,6 +1078,7 @@ export async function monitorWebProvider(
const processMessage = async ( const processMessage = async (
msg: WebInboundMsg, msg: WebInboundMsg,
route: ReturnType<typeof resolveAgentRoute>, route: ReturnType<typeof resolveAgentRoute>,
groupHistoryKey: string,
) => { ) => {
status.lastMessageAt = Date.now(); status.lastMessageAt = Date.now();
status.lastEventAt = status.lastMessageAt; status.lastEventAt = status.lastMessageAt;
@@ -1086,7 +1088,7 @@ export async function monitorWebProvider(
let shouldClearGroupHistory = false; let shouldClearGroupHistory = false;
if (msg.chatType === "group") { if (msg.chatType === "group") {
const history = groupHistories.get(route.sessionKey) ?? []; const history = groupHistories.get(groupHistoryKey) ?? [];
const historyWithoutCurrent = const historyWithoutCurrent =
history.length > 0 ? history.slice(0, -1) : []; history.length > 0 ? history.slice(0, -1) : [];
if (historyWithoutCurrent.length > 0) { if (historyWithoutCurrent.length > 0) {
@@ -1298,7 +1300,7 @@ export async function monitorWebProvider(
markDispatchIdle(); markDispatchIdle();
if (!queuedFinal) { if (!queuedFinal) {
if (shouldClearGroupHistory && didSendReply) { if (shouldClearGroupHistory && didSendReply) {
groupHistories.set(route.sessionKey, []); groupHistories.set(groupHistoryKey, []);
} }
logVerbose( logVerbose(
"Skipping auto-reply: silent token or no text/media returned from resolver", "Skipping auto-reply: silent token or no text/media returned from resolver",
@@ -1307,7 +1309,7 @@ export async function monitorWebProvider(
} }
if (shouldClearGroupHistory && didSendReply) { if (shouldClearGroupHistory && didSendReply) {
groupHistories.set(route.sessionKey, []); groupHistories.set(groupHistoryKey, []);
} }
}; };
@@ -1345,7 +1347,10 @@ export async function monitorWebProvider(
id: peerId, id: peerId,
}, },
}); });
const groupHistoryKey = route.sessionKey; const groupHistoryKey =
msg.chatType === "group"
? `whatsapp:${route.accountId}:group:${peerId.trim() || "unknown"}`
: route.sessionKey;
// Same-phone mode logging retained // Same-phone mode logging retained
if (msg.from === msg.to) { if (msg.from === msg.to) {
@@ -1460,29 +1465,42 @@ export async function monitorWebProvider(
} }
} }
// Check for broadcast groups // Broadcast groups: when we'd reply anyway, run multiple agents.
const broadcastAgents = cfg.routing?.broadcast?.[peerId]; // Does not bypass group mention/activation gating above (Option A).
const broadcastAgents = cfg.broadcast?.[peerId];
if ( if (
broadcastAgents && broadcastAgents &&
Array.isArray(broadcastAgents) && Array.isArray(broadcastAgents) &&
broadcastAgents.length > 0 broadcastAgents.length > 0
) { ) {
const strategy = cfg.routing?.broadcast?.strategy || "parallel"; const strategy = cfg.broadcast?.strategy || "parallel";
whatsappInboundLog.info( whatsappInboundLog.info(
`Broadcasting message to ${broadcastAgents.length} agents (${strategy})`, `Broadcasting message to ${broadcastAgents.length} agents (${strategy})`,
); );
const agentIds = cfg.agents?.list?.map((agent) =>
normalizeAgentId(agent.id),
);
const hasKnownAgents = (agentIds?.length ?? 0) > 0;
const processForAgent = (agentId: string) => { const processForAgent = (agentId: string) => {
const normalizedAgentId = normalizeAgentId(agentId); const normalizedAgentId = normalizeAgentId(agentId);
if (hasKnownAgents && !agentIds?.includes(normalizedAgentId)) {
whatsappInboundLog.warn(
`Broadcast agent ${agentId} not found in agents.list; skipping`,
);
return Promise.resolve();
}
const agentRoute = { const agentRoute = {
...route, ...route,
agentId: normalizedAgentId, agentId: normalizedAgentId,
sessionKey: buildAgentPeerSessionKey({ sessionKey: buildAgentSessionKey({
agentId: normalizedAgentId, agentId: normalizedAgentId,
mainKey: DEFAULT_MAIN_KEY,
provider: "whatsapp", provider: "whatsapp",
peerKind: msg.chatType === "group" ? "group" : "dm", peer: {
peerId: normalizeId(peerId), kind: msg.chatType === "group" ? "group" : "dm",
id: peerId,
},
}), }),
mainSessionKey: buildAgentMainSessionKey({ mainSessionKey: buildAgentMainSessionKey({
agentId: normalizedAgentId, agentId: normalizedAgentId,
@@ -1490,11 +1508,13 @@ export async function monitorWebProvider(
}), }),
}; };
return processMessage(msg, agentRoute).catch((err) => { return processMessage(msg, agentRoute, groupHistoryKey).catch(
(err) => {
whatsappInboundLog.error( whatsappInboundLog.error(
`Broadcast agent ${agentId} failed: ${formatError(err)}`, `Broadcast agent ${agentId} failed: ${formatError(err)}`,
); );
}); },
);
}; };
if (strategy === "sequential") { if (strategy === "sequential") {
@@ -1509,7 +1529,7 @@ export async function monitorWebProvider(
return; return;
} }
return processMessage(msg, route); return processMessage(msg, route, groupHistoryKey);
}, },
}); });