Merge 6a75ce9dac into 4de0bae45a
This commit is contained in:
commit
7e56ffe885
@ -55,6 +55,7 @@ Payload:
|
||||
{
|
||||
"message": "Run this",
|
||||
"name": "Email",
|
||||
"agentId": "email-handler",
|
||||
"sessionKey": "hook:email:msg-123",
|
||||
"wakeMode": "now",
|
||||
"deliver": true,
|
||||
@ -69,6 +70,7 @@ Payload:
|
||||
- `message` **required** (string): The prompt or message for the agent to process.
|
||||
- `name` optional (string): Human-readable name for the hook (e.g., "GitHub"), used as a prefix in session summaries.
|
||||
- `sessionKey` optional (string): The key used to identify the agent's session. Defaults to a random `hook:<uuid>`. Using a consistent key allows for a multi-turn conversation within the hook context.
|
||||
- `agentId` optional (string): Target agent to run this hook. Must exist in `agents.list`. Defaults to the default agent. Useful for routing webhooks to specialized agents with their own tools, workspace, and persona.
|
||||
- `wakeMode` optional (`now` | `next-heartbeat`): Whether to trigger an immediate heartbeat (default `now`) or wait for the next periodic check.
|
||||
- `deliver` optional (boolean): If `true`, the agent's response will be sent to the messaging channel. Defaults to `true`. Responses that are only heartbeat acknowledgments are automatically skipped.
|
||||
- `channel` optional (string): The messaging channel for delivery. One of: `last`, `whatsapp`, `telegram`, `discord`, `slack`, `mattermost` (plugin), `signal`, `imessage`, `msteams`. Defaults to `last`.
|
||||
@ -94,6 +96,7 @@ Mapping options (summary):
|
||||
- `hooks.transformsDir` + `transform.module` loads a JS/TS module for custom logic.
|
||||
- Use `match.source` to keep a generic ingest endpoint (payload-driven routing).
|
||||
- TS transforms require a TS loader (e.g. `bun` or `tsx`) or precompiled `.js` at runtime.
|
||||
- Set `agentId` to route the hook to a specific agent (must exist in `agents.list`).
|
||||
- Set `deliver: true` + `channel`/`to` on mappings to route replies to a chat surface
|
||||
(`channel` defaults to `last` and falls back to WhatsApp).
|
||||
- `allowUnsafeExternalContent: true` disables the external content safety wrapper for that hook
|
||||
@ -145,6 +148,30 @@ curl -X POST http://127.0.0.1:18789/hooks/gmail \
|
||||
-d '{"source":"gmail","messages":[{"from":"Ada","subject":"Hello","snippet":"Hi"}]}'
|
||||
```
|
||||
|
||||
### Route to a specific agent
|
||||
|
||||
Use `agentId` to route webhooks to a dedicated agent in multi-agent setups:
|
||||
|
||||
```json5
|
||||
{
|
||||
hooks: {
|
||||
mappings: [
|
||||
{
|
||||
match: { path: "/email" },
|
||||
action: "agent",
|
||||
agentId: "email-handler", // Must exist in agents.list
|
||||
messageTemplate: "New email from {{from}}: {{subject}}",
|
||||
deliver: true,
|
||||
channel: "telegram",
|
||||
to: "123456789"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This lets you have specialized agents with their own tools, workspace, and persona for different webhook sources (e.g., email processing, GitHub webhooks, support inbox).
|
||||
|
||||
## Security
|
||||
|
||||
- Keep hook endpoints behind loopback, tailnet, or trusted reverse proxy.
|
||||
|
||||
100
src/config/config.hooks-mapping-agentid.test.ts
Normal file
100
src/config/config.hooks-mapping-agentid.test.ts
Normal file
@ -0,0 +1,100 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
describe("hooks.mappings agentId validation", () => {
|
||||
it("accepts valid agentId in hook mapping", async () => {
|
||||
vi.resetModules();
|
||||
const { validateConfigObject } = await import("./config.js");
|
||||
const res = validateConfigObject({
|
||||
agents: {
|
||||
list: [{ id: "email-handler" }, { id: "default" }],
|
||||
},
|
||||
hooks: {
|
||||
enabled: true,
|
||||
token: "test-token",
|
||||
mappings: [
|
||||
{
|
||||
id: "email-hook",
|
||||
match: { path: "email" },
|
||||
action: "agent",
|
||||
agentId: "email-handler",
|
||||
messageTemplate: "New email",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects unknown agentId in hook mapping", async () => {
|
||||
vi.resetModules();
|
||||
const { validateConfigObject } = await import("./config.js");
|
||||
const res = validateConfigObject({
|
||||
agents: {
|
||||
list: [{ id: "default" }],
|
||||
},
|
||||
hooks: {
|
||||
enabled: true,
|
||||
token: "test-token",
|
||||
mappings: [
|
||||
{
|
||||
id: "email-hook",
|
||||
match: { path: "email" },
|
||||
action: "agent",
|
||||
agentId: "nonexistent-agent",
|
||||
messageTemplate: "New email",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
if (!res.ok) {
|
||||
const messages = res.issues.map((i) => i.message).join(" ");
|
||||
expect(messages).toContain("nonexistent-agent");
|
||||
expect(messages).toContain("not in agents.list");
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts hook mapping without agentId (uses default)", async () => {
|
||||
vi.resetModules();
|
||||
const { validateConfigObject } = await import("./config.js");
|
||||
const res = validateConfigObject({
|
||||
agents: {
|
||||
list: [{ id: "default" }],
|
||||
},
|
||||
hooks: {
|
||||
enabled: true,
|
||||
token: "test-token",
|
||||
mappings: [
|
||||
{
|
||||
id: "email-hook",
|
||||
match: { path: "email" },
|
||||
action: "agent",
|
||||
messageTemplate: "New email",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("skips agentId validation when agents.list is empty", async () => {
|
||||
vi.resetModules();
|
||||
const { validateConfigObject } = await import("./config.js");
|
||||
const res = validateConfigObject({
|
||||
hooks: {
|
||||
enabled: true,
|
||||
token: "test-token",
|
||||
mappings: [
|
||||
{
|
||||
id: "email-hook",
|
||||
match: { path: "email" },
|
||||
action: "agent",
|
||||
agentId: "any-agent",
|
||||
messageTemplate: "New email",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
});
|
||||
});
|
||||
@ -12,6 +12,8 @@ export type HookMappingConfig = {
|
||||
id?: string;
|
||||
match?: HookMappingMatch;
|
||||
action?: "wake" | "agent";
|
||||
/** Target agent for action: "agent". Must exist in agents.list. */
|
||||
agentId?: string;
|
||||
wakeMode?: "now" | "next-heartbeat";
|
||||
name?: string;
|
||||
sessionKey?: string;
|
||||
|
||||
@ -10,6 +10,7 @@ export const HookMappingSchema = z
|
||||
})
|
||||
.optional(),
|
||||
action: z.union([z.literal("wake"), z.literal("agent")]).optional(),
|
||||
agentId: z.string().optional(),
|
||||
wakeMode: z.union([z.literal("now"), z.literal("next-heartbeat")]).optional(),
|
||||
name: z.string().optional(),
|
||||
sessionKey: z.string().optional(),
|
||||
|
||||
@ -536,20 +536,34 @@ export const OpenClawSchema = z
|
||||
const agentIds = new Set(agents.map((agent) => agent.id));
|
||||
|
||||
const broadcast = cfg.broadcast;
|
||||
if (!broadcast) return;
|
||||
|
||||
for (const [peerId, ids] of Object.entries(broadcast)) {
|
||||
if (peerId === "strategy") continue;
|
||||
if (!Array.isArray(ids)) continue;
|
||||
for (let idx = 0; idx < ids.length; idx += 1) {
|
||||
const agentId = ids[idx];
|
||||
if (!agentIds.has(agentId)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["broadcast", peerId, idx],
|
||||
message: `Unknown agent id "${agentId}" (not in agents.list).`,
|
||||
});
|
||||
if (broadcast) {
|
||||
for (const [peerId, ids] of Object.entries(broadcast)) {
|
||||
if (peerId === "strategy") continue;
|
||||
if (!Array.isArray(ids)) continue;
|
||||
for (let idx = 0; idx < ids.length; idx += 1) {
|
||||
const agentId = ids[idx];
|
||||
if (!agentIds.has(agentId)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["broadcast", peerId, idx],
|
||||
message: `Unknown agent id "${agentId}" (not in agents.list).`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const hookMappings = cfg.hooks?.mappings ?? [];
|
||||
for (let idx = 0; idx < hookMappings.length; idx += 1) {
|
||||
const mapping = hookMappings[idx];
|
||||
if (!mapping) continue;
|
||||
const agentId = mapping.agentId?.trim();
|
||||
if (agentId && !agentIds.has(agentId)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["hooks", "mappings", idx, "agentId"],
|
||||
message: `Unknown agent id "${agentId}" (not in agents.list).`,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@ -165,4 +165,52 @@ describe("hooks mapping", () => {
|
||||
});
|
||||
expect(result?.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("passes agentId from mapping to action", async () => {
|
||||
const mappings = resolveHookMappings({
|
||||
mappings: [
|
||||
{
|
||||
id: "email-hook",
|
||||
match: { path: "email" },
|
||||
action: "agent",
|
||||
agentId: "email-handler",
|
||||
messageTemplate: "New email: {{subject}}",
|
||||
},
|
||||
],
|
||||
});
|
||||
const result = await applyHookMappings(mappings, {
|
||||
payload: { subject: "Test" },
|
||||
headers: {},
|
||||
url: new URL("http://127.0.0.1:18789/hooks/email"),
|
||||
path: "email",
|
||||
});
|
||||
expect(result?.ok).toBe(true);
|
||||
if (result?.ok && result.action?.kind === "agent") {
|
||||
expect(result.action.agentId).toBe("email-handler");
|
||||
expect(result.action.message).toBe("New email: Test");
|
||||
}
|
||||
});
|
||||
|
||||
it("omits agentId when not specified", async () => {
|
||||
const mappings = resolveHookMappings({
|
||||
mappings: [
|
||||
{
|
||||
id: "default-hook",
|
||||
match: { path: "default" },
|
||||
action: "agent",
|
||||
messageTemplate: "Message: {{text}}",
|
||||
},
|
||||
],
|
||||
});
|
||||
const result = await applyHookMappings(mappings, {
|
||||
payload: { text: "Hello" },
|
||||
headers: {},
|
||||
url: new URL("http://127.0.0.1:18789/hooks/default"),
|
||||
path: "default",
|
||||
});
|
||||
expect(result?.ok).toBe(true);
|
||||
if (result?.ok && result.action?.kind === "agent") {
|
||||
expect(result.action.agentId).toBeUndefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@ -9,6 +9,7 @@ export type HookMappingResolved = {
|
||||
matchPath?: string;
|
||||
matchSource?: string;
|
||||
action: "wake" | "agent";
|
||||
agentId?: string;
|
||||
wakeMode?: "now" | "next-heartbeat";
|
||||
name?: string;
|
||||
sessionKey?: string;
|
||||
@ -45,6 +46,7 @@ export type HookAction =
|
||||
| {
|
||||
kind: "agent";
|
||||
message: string;
|
||||
agentId?: string;
|
||||
name?: string;
|
||||
wakeMode: "now" | "next-heartbeat";
|
||||
sessionKey?: string;
|
||||
@ -84,6 +86,7 @@ type HookTransformResult = Partial<{
|
||||
text: string;
|
||||
mode: "now" | "next-heartbeat";
|
||||
message: string;
|
||||
agentId: string;
|
||||
wakeMode: "now" | "next-heartbeat";
|
||||
name: string;
|
||||
sessionKey: string;
|
||||
@ -166,6 +169,7 @@ function normalizeHookMapping(
|
||||
const matchPath = normalizeMatchPath(mapping.match?.path);
|
||||
const matchSource = mapping.match?.source?.trim();
|
||||
const action = mapping.action ?? "agent";
|
||||
const agentId = mapping.agentId?.trim() || undefined;
|
||||
const wakeMode = mapping.wakeMode ?? "now";
|
||||
const transform = mapping.transform
|
||||
? {
|
||||
@ -179,6 +183,7 @@ function normalizeHookMapping(
|
||||
matchPath,
|
||||
matchSource,
|
||||
action,
|
||||
agentId,
|
||||
wakeMode,
|
||||
name: mapping.name,
|
||||
sessionKey: mapping.sessionKey,
|
||||
@ -227,6 +232,7 @@ function buildActionFromMapping(
|
||||
action: {
|
||||
kind: "agent",
|
||||
message,
|
||||
agentId: mapping.agentId,
|
||||
name: renderOptional(mapping.name, ctx),
|
||||
wakeMode: mapping.wakeMode ?? "now",
|
||||
sessionKey: renderOptional(mapping.sessionKey, ctx),
|
||||
@ -264,6 +270,7 @@ function mergeAction(
|
||||
return validateAction({
|
||||
kind: "agent",
|
||||
message,
|
||||
agentId: override.agentId ?? baseAgent?.agentId,
|
||||
wakeMode,
|
||||
name: override.name ?? baseAgent?.name,
|
||||
sessionKey: override.sessionKey ?? baseAgent?.sessionKey,
|
||||
|
||||
@ -130,6 +130,7 @@ export function normalizeWakePayload(
|
||||
|
||||
export type HookAgentPayload = {
|
||||
message: string;
|
||||
agentId?: string;
|
||||
name: string;
|
||||
wakeMode: "now" | "next-heartbeat";
|
||||
sessionKey: string;
|
||||
@ -171,6 +172,9 @@ export function normalizeAgentPayload(
|
||||
| { ok: false; error: string } {
|
||||
const message = typeof payload.message === "string" ? payload.message.trim() : "";
|
||||
if (!message) return { ok: false, error: "message required" };
|
||||
const agentIdRaw = payload.agentId;
|
||||
const agentId =
|
||||
typeof agentIdRaw === "string" && agentIdRaw.trim() ? agentIdRaw.trim() : undefined;
|
||||
const nameRaw = payload.name;
|
||||
const name = typeof nameRaw === "string" && nameRaw.trim() ? nameRaw.trim() : "Hook";
|
||||
const wakeMode = payload.wakeMode === "next-heartbeat" ? "next-heartbeat" : "now";
|
||||
@ -202,6 +206,7 @@ export function normalizeAgentPayload(
|
||||
ok: true,
|
||||
value: {
|
||||
message,
|
||||
agentId,
|
||||
name,
|
||||
wakeMode,
|
||||
sessionKey,
|
||||
|
||||
@ -37,6 +37,7 @@ type HookDispatchers = {
|
||||
dispatchWakeHook: (value: { text: string; mode: "now" | "next-heartbeat" }) => void;
|
||||
dispatchAgentHook: (value: {
|
||||
message: string;
|
||||
agentId?: string;
|
||||
name: string;
|
||||
wakeMode: "now" | "next-heartbeat";
|
||||
sessionKey: string;
|
||||
@ -172,6 +173,7 @@ export function createHooksRequestHandler(
|
||||
}
|
||||
const runId = dispatchAgentHook({
|
||||
message: mapped.action.message,
|
||||
agentId: mapped.action.agentId,
|
||||
name: mapped.action.name ?? "Hook",
|
||||
wakeMode: mapped.action.wakeMode,
|
||||
sessionKey: mapped.action.sessionKey ?? "",
|
||||
|
||||
@ -32,6 +32,7 @@ export function createGatewayHooksRequestHandler(params: {
|
||||
|
||||
const dispatchAgentHook = (value: {
|
||||
message: string;
|
||||
agentId?: string;
|
||||
name: string;
|
||||
wakeMode: "now" | "next-heartbeat";
|
||||
sessionKey: string;
|
||||
@ -49,6 +50,7 @@ export function createGatewayHooksRequestHandler(params: {
|
||||
const now = Date.now();
|
||||
const job: CronJob = {
|
||||
id: jobId,
|
||||
agentId: value.agentId,
|
||||
name: value.name,
|
||||
enabled: true,
|
||||
createdAtMs: now,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user