Merge 6a75ce9dac into 4de0bae45a
This commit is contained in:
commit
7e56ffe885
@ -55,6 +55,7 @@ Payload:
|
|||||||
{
|
{
|
||||||
"message": "Run this",
|
"message": "Run this",
|
||||||
"name": "Email",
|
"name": "Email",
|
||||||
|
"agentId": "email-handler",
|
||||||
"sessionKey": "hook:email:msg-123",
|
"sessionKey": "hook:email:msg-123",
|
||||||
"wakeMode": "now",
|
"wakeMode": "now",
|
||||||
"deliver": true,
|
"deliver": true,
|
||||||
@ -69,6 +70,7 @@ Payload:
|
|||||||
- `message` **required** (string): The prompt or message for the agent to process.
|
- `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.
|
- `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.
|
- `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.
|
- `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.
|
- `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`.
|
- `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.
|
- `hooks.transformsDir` + `transform.module` loads a JS/TS module for custom logic.
|
||||||
- Use `match.source` to keep a generic ingest endpoint (payload-driven routing).
|
- 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.
|
- 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
|
- Set `deliver: true` + `channel`/`to` on mappings to route replies to a chat surface
|
||||||
(`channel` defaults to `last` and falls back to WhatsApp).
|
(`channel` defaults to `last` and falls back to WhatsApp).
|
||||||
- `allowUnsafeExternalContent: true` disables the external content safety wrapper for that hook
|
- `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"}]}'
|
-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
|
## Security
|
||||||
|
|
||||||
- Keep hook endpoints behind loopback, tailnet, or trusted reverse proxy.
|
- 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;
|
id?: string;
|
||||||
match?: HookMappingMatch;
|
match?: HookMappingMatch;
|
||||||
action?: "wake" | "agent";
|
action?: "wake" | "agent";
|
||||||
|
/** Target agent for action: "agent". Must exist in agents.list. */
|
||||||
|
agentId?: string;
|
||||||
wakeMode?: "now" | "next-heartbeat";
|
wakeMode?: "now" | "next-heartbeat";
|
||||||
name?: string;
|
name?: string;
|
||||||
sessionKey?: string;
|
sessionKey?: string;
|
||||||
|
|||||||
@ -10,6 +10,7 @@ export const HookMappingSchema = z
|
|||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
action: z.union([z.literal("wake"), z.literal("agent")]).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(),
|
wakeMode: z.union([z.literal("now"), z.literal("next-heartbeat")]).optional(),
|
||||||
name: z.string().optional(),
|
name: z.string().optional(),
|
||||||
sessionKey: 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 agentIds = new Set(agents.map((agent) => agent.id));
|
||||||
|
|
||||||
const broadcast = cfg.broadcast;
|
const broadcast = cfg.broadcast;
|
||||||
if (!broadcast) return;
|
if (broadcast) {
|
||||||
|
for (const [peerId, ids] of Object.entries(broadcast)) {
|
||||||
for (const [peerId, ids] of Object.entries(broadcast)) {
|
if (peerId === "strategy") continue;
|
||||||
if (peerId === "strategy") continue;
|
if (!Array.isArray(ids)) continue;
|
||||||
if (!Array.isArray(ids)) continue;
|
for (let idx = 0; idx < ids.length; idx += 1) {
|
||||||
for (let idx = 0; idx < ids.length; idx += 1) {
|
const agentId = ids[idx];
|
||||||
const agentId = ids[idx];
|
if (!agentIds.has(agentId)) {
|
||||||
if (!agentIds.has(agentId)) {
|
ctx.addIssue({
|
||||||
ctx.addIssue({
|
code: z.ZodIssueCode.custom,
|
||||||
code: z.ZodIssueCode.custom,
|
path: ["broadcast", peerId, idx],
|
||||||
path: ["broadcast", peerId, idx],
|
message: `Unknown agent id "${agentId}" (not in agents.list).`,
|
||||||
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);
|
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;
|
matchPath?: string;
|
||||||
matchSource?: string;
|
matchSource?: string;
|
||||||
action: "wake" | "agent";
|
action: "wake" | "agent";
|
||||||
|
agentId?: string;
|
||||||
wakeMode?: "now" | "next-heartbeat";
|
wakeMode?: "now" | "next-heartbeat";
|
||||||
name?: string;
|
name?: string;
|
||||||
sessionKey?: string;
|
sessionKey?: string;
|
||||||
@ -45,6 +46,7 @@ export type HookAction =
|
|||||||
| {
|
| {
|
||||||
kind: "agent";
|
kind: "agent";
|
||||||
message: string;
|
message: string;
|
||||||
|
agentId?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
wakeMode: "now" | "next-heartbeat";
|
wakeMode: "now" | "next-heartbeat";
|
||||||
sessionKey?: string;
|
sessionKey?: string;
|
||||||
@ -84,6 +86,7 @@ type HookTransformResult = Partial<{
|
|||||||
text: string;
|
text: string;
|
||||||
mode: "now" | "next-heartbeat";
|
mode: "now" | "next-heartbeat";
|
||||||
message: string;
|
message: string;
|
||||||
|
agentId: string;
|
||||||
wakeMode: "now" | "next-heartbeat";
|
wakeMode: "now" | "next-heartbeat";
|
||||||
name: string;
|
name: string;
|
||||||
sessionKey: string;
|
sessionKey: string;
|
||||||
@ -166,6 +169,7 @@ function normalizeHookMapping(
|
|||||||
const matchPath = normalizeMatchPath(mapping.match?.path);
|
const matchPath = normalizeMatchPath(mapping.match?.path);
|
||||||
const matchSource = mapping.match?.source?.trim();
|
const matchSource = mapping.match?.source?.trim();
|
||||||
const action = mapping.action ?? "agent";
|
const action = mapping.action ?? "agent";
|
||||||
|
const agentId = mapping.agentId?.trim() || undefined;
|
||||||
const wakeMode = mapping.wakeMode ?? "now";
|
const wakeMode = mapping.wakeMode ?? "now";
|
||||||
const transform = mapping.transform
|
const transform = mapping.transform
|
||||||
? {
|
? {
|
||||||
@ -179,6 +183,7 @@ function normalizeHookMapping(
|
|||||||
matchPath,
|
matchPath,
|
||||||
matchSource,
|
matchSource,
|
||||||
action,
|
action,
|
||||||
|
agentId,
|
||||||
wakeMode,
|
wakeMode,
|
||||||
name: mapping.name,
|
name: mapping.name,
|
||||||
sessionKey: mapping.sessionKey,
|
sessionKey: mapping.sessionKey,
|
||||||
@ -227,6 +232,7 @@ function buildActionFromMapping(
|
|||||||
action: {
|
action: {
|
||||||
kind: "agent",
|
kind: "agent",
|
||||||
message,
|
message,
|
||||||
|
agentId: mapping.agentId,
|
||||||
name: renderOptional(mapping.name, ctx),
|
name: renderOptional(mapping.name, ctx),
|
||||||
wakeMode: mapping.wakeMode ?? "now",
|
wakeMode: mapping.wakeMode ?? "now",
|
||||||
sessionKey: renderOptional(mapping.sessionKey, ctx),
|
sessionKey: renderOptional(mapping.sessionKey, ctx),
|
||||||
@ -264,6 +270,7 @@ function mergeAction(
|
|||||||
return validateAction({
|
return validateAction({
|
||||||
kind: "agent",
|
kind: "agent",
|
||||||
message,
|
message,
|
||||||
|
agentId: override.agentId ?? baseAgent?.agentId,
|
||||||
wakeMode,
|
wakeMode,
|
||||||
name: override.name ?? baseAgent?.name,
|
name: override.name ?? baseAgent?.name,
|
||||||
sessionKey: override.sessionKey ?? baseAgent?.sessionKey,
|
sessionKey: override.sessionKey ?? baseAgent?.sessionKey,
|
||||||
|
|||||||
@ -130,6 +130,7 @@ export function normalizeWakePayload(
|
|||||||
|
|
||||||
export type HookAgentPayload = {
|
export type HookAgentPayload = {
|
||||||
message: string;
|
message: string;
|
||||||
|
agentId?: string;
|
||||||
name: string;
|
name: string;
|
||||||
wakeMode: "now" | "next-heartbeat";
|
wakeMode: "now" | "next-heartbeat";
|
||||||
sessionKey: string;
|
sessionKey: string;
|
||||||
@ -171,6 +172,9 @@ export function normalizeAgentPayload(
|
|||||||
| { ok: false; error: string } {
|
| { ok: false; error: string } {
|
||||||
const message = typeof payload.message === "string" ? payload.message.trim() : "";
|
const message = typeof payload.message === "string" ? payload.message.trim() : "";
|
||||||
if (!message) return { ok: false, error: "message required" };
|
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 nameRaw = payload.name;
|
||||||
const name = typeof nameRaw === "string" && nameRaw.trim() ? nameRaw.trim() : "Hook";
|
const name = typeof nameRaw === "string" && nameRaw.trim() ? nameRaw.trim() : "Hook";
|
||||||
const wakeMode = payload.wakeMode === "next-heartbeat" ? "next-heartbeat" : "now";
|
const wakeMode = payload.wakeMode === "next-heartbeat" ? "next-heartbeat" : "now";
|
||||||
@ -202,6 +206,7 @@ export function normalizeAgentPayload(
|
|||||||
ok: true,
|
ok: true,
|
||||||
value: {
|
value: {
|
||||||
message,
|
message,
|
||||||
|
agentId,
|
||||||
name,
|
name,
|
||||||
wakeMode,
|
wakeMode,
|
||||||
sessionKey,
|
sessionKey,
|
||||||
|
|||||||
@ -37,6 +37,7 @@ type HookDispatchers = {
|
|||||||
dispatchWakeHook: (value: { text: string; mode: "now" | "next-heartbeat" }) => void;
|
dispatchWakeHook: (value: { text: string; mode: "now" | "next-heartbeat" }) => void;
|
||||||
dispatchAgentHook: (value: {
|
dispatchAgentHook: (value: {
|
||||||
message: string;
|
message: string;
|
||||||
|
agentId?: string;
|
||||||
name: string;
|
name: string;
|
||||||
wakeMode: "now" | "next-heartbeat";
|
wakeMode: "now" | "next-heartbeat";
|
||||||
sessionKey: string;
|
sessionKey: string;
|
||||||
@ -172,6 +173,7 @@ export function createHooksRequestHandler(
|
|||||||
}
|
}
|
||||||
const runId = dispatchAgentHook({
|
const runId = dispatchAgentHook({
|
||||||
message: mapped.action.message,
|
message: mapped.action.message,
|
||||||
|
agentId: mapped.action.agentId,
|
||||||
name: mapped.action.name ?? "Hook",
|
name: mapped.action.name ?? "Hook",
|
||||||
wakeMode: mapped.action.wakeMode,
|
wakeMode: mapped.action.wakeMode,
|
||||||
sessionKey: mapped.action.sessionKey ?? "",
|
sessionKey: mapped.action.sessionKey ?? "",
|
||||||
|
|||||||
@ -32,6 +32,7 @@ export function createGatewayHooksRequestHandler(params: {
|
|||||||
|
|
||||||
const dispatchAgentHook = (value: {
|
const dispatchAgentHook = (value: {
|
||||||
message: string;
|
message: string;
|
||||||
|
agentId?: string;
|
||||||
name: string;
|
name: string;
|
||||||
wakeMode: "now" | "next-heartbeat";
|
wakeMode: "now" | "next-heartbeat";
|
||||||
sessionKey: string;
|
sessionKey: string;
|
||||||
@ -49,6 +50,7 @@ export function createGatewayHooksRequestHandler(params: {
|
|||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const job: CronJob = {
|
const job: CronJob = {
|
||||||
id: jobId,
|
id: jobId,
|
||||||
|
agentId: value.agentId,
|
||||||
name: value.name,
|
name: value.name,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
createdAtMs: now,
|
createdAtMs: now,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user