@coffeexdev/openclaw-sentinel 0.4.5 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +56 -5
- package/dist/callbackEnvelope.js +29 -0
- package/dist/configSchema.js +42 -0
- package/dist/index.js +257 -43
- package/dist/tool.js +29 -2
- package/dist/types.d.ts +8 -0
- package/dist/types.js +4 -0
- package/openclaw.plugin.json +36 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -39,6 +39,17 @@ Add/update `~/.openclaw/openclaw.json`:
|
|
|
39
39
|
// Optional: suppress duplicate relays by dedupe key within this time window.
|
|
40
40
|
hookRelayDedupeWindowMs: 120000,
|
|
41
41
|
|
|
42
|
+
// Optional: guarantee hook-response delivery contract for /hooks/sentinel callbacks.
|
|
43
|
+
// Wait this long for assistant-authored output before fallback behavior applies.
|
|
44
|
+
hookResponseTimeoutMs: 30000,
|
|
45
|
+
|
|
46
|
+
// Optional: timeout fallback relay mode for /hooks/sentinel response contracts.
|
|
47
|
+
// "none" = no fallback message, "concise" = send a fail-safe relay line.
|
|
48
|
+
hookResponseFallbackMode: "concise",
|
|
49
|
+
|
|
50
|
+
// Optional: dedupe repeated callback response contracts by dedupe key.
|
|
51
|
+
hookResponseDedupeWindowMs: 120000,
|
|
52
|
+
|
|
42
53
|
// Optional: payload style for non-/hooks/sentinel deliveryTargets notifications.
|
|
43
54
|
// "none" suppresses delivery-target message fan-out (callback still fires).
|
|
44
55
|
// "concise" (default) sends human-friendly relay text only.
|
|
@@ -134,12 +145,13 @@ Use `sentinel_control`:
|
|
|
134
145
|
|
|
135
146
|
1. Sentinel evaluates conditions.
|
|
136
147
|
2. On match, it dispatches a generic callback envelope (`type: "sentinel.callback"`) to `localDispatchBase + webhookPath`.
|
|
137
|
-
3. The envelope includes stable keys (`intent`, `context`, `watcher`, `trigger`, bounded `payload`, `deliveryTargets`, `source`) so downstream agent behavior is workflow-agnostic.
|
|
138
|
-
4. For `/hooks/sentinel`,
|
|
139
|
-
5. The
|
|
148
|
+
3. The envelope includes stable keys (`intent`, `context`, `watcher`, `trigger`, bounded `payload`, `deliveryTargets`, `deliveryContext`, `source`) so downstream agent behavior is workflow-agnostic.
|
|
149
|
+
4. For `/hooks/sentinel`, Sentinel enqueues an instruction-prefixed system event plus structured JSON envelope and requests heartbeat wake.
|
|
150
|
+
5. The hook route creates a **response-delivery contract** keyed by callback dedupe key, preserving original chat/session context (`deliveryContext`) and intended relay targets.
|
|
140
151
|
6. OpenClaw processes each callback in an isolated hook session: per-watcher by default, or grouped when `hookSessionGroup` / `fire.sessionGroup` is set. Shared global hook-session mode is intentionally not supported.
|
|
152
|
+
7. When hook-session LLM output arrives, Sentinel relays assistant-authored text to the original chat context. If no assistant output arrives before `hookResponseTimeoutMs`, optional fallback relay behavior is applied (`hookResponseFallbackMode`).
|
|
141
153
|
|
|
142
|
-
The `/hooks/sentinel` route is auto-registered on plugin startup (idempotent).
|
|
154
|
+
The `/hooks/sentinel` route is auto-registered on plugin startup (idempotent). Response contracts are dedupe-aware by callback dedupe key (`hookResponseDedupeWindowMs`).
|
|
143
155
|
|
|
144
156
|
Sample emitted envelope:
|
|
145
157
|
|
|
@@ -158,6 +170,12 @@ Sample emitted envelope:
|
|
|
158
170
|
"context": { "asset": "ETH", "priceUsd": 5001, "workflow": "alerts" },
|
|
159
171
|
"payload": { "ethereum": { "usd": 5001 } },
|
|
160
172
|
"deliveryTargets": [{ "channel": "telegram", "to": "5613673222" }],
|
|
173
|
+
"deliveryContext": {
|
|
174
|
+
"sessionKey": "agent:main:telegram:direct:5613673222",
|
|
175
|
+
"messageChannel": "telegram",
|
|
176
|
+
"requesterSenderId": "5613673222",
|
|
177
|
+
"currentChat": { "channel": "telegram", "to": "5613673222" }
|
|
178
|
+
},
|
|
161
179
|
"source": { "plugin": "openclaw-sentinel", "route": "/hooks/sentinel" }
|
|
162
180
|
}
|
|
163
181
|
```
|
|
@@ -226,7 +244,8 @@ It **does not** execute user-authored code from watcher definitions.
|
|
|
226
244
|
## Notification payload delivery modes
|
|
227
245
|
|
|
228
246
|
Sentinel always dispatches the callback envelope to `localDispatchBase + webhookPath` on match.
|
|
229
|
-
`notificationPayloadMode` only controls **additional fan-out messages** to `deliveryTargets
|
|
247
|
+
`notificationPayloadMode` only controls **additional fan-out messages** to `deliveryTargets` for watcher dispatches (for example `/hooks/agent`).
|
|
248
|
+
It does **not** control `/hooks/sentinel` hook-response contracts or assistant-output relay behavior.
|
|
230
249
|
|
|
231
250
|
Global mode options:
|
|
232
251
|
|
|
@@ -308,6 +327,38 @@ Precedence: **watcher override > global setting**.
|
|
|
308
327
|
- Existing installs keep default behavior (`concise`) unless you set `notificationPayloadMode` explicitly.
|
|
309
328
|
- If you want callback-only operation (wake LLM loop via `/hooks/sentinel` but no delivery-target chat message), set global or per-watcher mode to `none`.
|
|
310
329
|
|
|
330
|
+
## Hook-response delivery contract (`/hooks/sentinel`)
|
|
331
|
+
|
|
332
|
+
`/hooks/sentinel` now enforces a dedicated trigger → LLM → user-visible relay contract:
|
|
333
|
+
|
|
334
|
+
1. Callback is enqueued to isolated hook session.
|
|
335
|
+
2. Contract captures original delivery context (`deliveryContext` + resolved `deliveryTargets`).
|
|
336
|
+
3. First assistant-authored `llm_output` for that pending callback is relayed to target chat.
|
|
337
|
+
4. If no assistant output arrives in time (`hookResponseTimeoutMs`), fallback is configurable:
|
|
338
|
+
- `hookResponseFallbackMode: "concise"` (default) sends a short fail-safe relay.
|
|
339
|
+
- `hookResponseFallbackMode: "none"` suppresses fallback.
|
|
340
|
+
5. Repeated callbacks with same dedupe key are idempotent within `hookResponseDedupeWindowMs`.
|
|
341
|
+
|
|
342
|
+
Example config:
|
|
343
|
+
|
|
344
|
+
```json5
|
|
345
|
+
{
|
|
346
|
+
plugins: {
|
|
347
|
+
entries: {
|
|
348
|
+
"openclaw-sentinel": {
|
|
349
|
+
enabled: true,
|
|
350
|
+
config: {
|
|
351
|
+
allowedHosts: ["api.github.com"],
|
|
352
|
+
hookResponseTimeoutMs: 30000,
|
|
353
|
+
hookResponseFallbackMode: "concise",
|
|
354
|
+
hookResponseDedupeWindowMs: 120000,
|
|
355
|
+
},
|
|
356
|
+
},
|
|
357
|
+
},
|
|
358
|
+
},
|
|
359
|
+
}
|
|
360
|
+
```
|
|
361
|
+
|
|
311
362
|
## Runtime controls
|
|
312
363
|
|
|
313
364
|
```json
|
package/dist/callbackEnvelope.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
+
import { SENTINEL_ORIGIN_ACCOUNT_METADATA, SENTINEL_ORIGIN_CHANNEL_METADATA, SENTINEL_ORIGIN_SESSION_KEY_METADATA, SENTINEL_ORIGIN_TARGET_METADATA, } from "./types.js";
|
|
2
3
|
import { renderTemplate } from "./template.js";
|
|
3
4
|
const MAX_PAYLOAD_JSON_CHARS = 4000;
|
|
4
5
|
function toIntent(eventName) {
|
|
@@ -30,6 +31,32 @@ function truncatePayload(payload) {
|
|
|
30
31
|
preview: serialized.slice(0, MAX_PAYLOAD_JSON_CHARS),
|
|
31
32
|
};
|
|
32
33
|
}
|
|
34
|
+
function buildDeliveryContextFromMetadata(watcher) {
|
|
35
|
+
const metadata = watcher.metadata;
|
|
36
|
+
if (!metadata)
|
|
37
|
+
return undefined;
|
|
38
|
+
const sessionKey = metadata[SENTINEL_ORIGIN_SESSION_KEY_METADATA]?.trim();
|
|
39
|
+
const channel = metadata[SENTINEL_ORIGIN_CHANNEL_METADATA]?.trim();
|
|
40
|
+
const to = metadata[SENTINEL_ORIGIN_TARGET_METADATA]?.trim();
|
|
41
|
+
const accountId = metadata[SENTINEL_ORIGIN_ACCOUNT_METADATA]?.trim();
|
|
42
|
+
const context = {};
|
|
43
|
+
if (sessionKey)
|
|
44
|
+
context.sessionKey = sessionKey;
|
|
45
|
+
if (channel)
|
|
46
|
+
context.messageChannel = channel;
|
|
47
|
+
if (to)
|
|
48
|
+
context.requesterSenderId = to;
|
|
49
|
+
if (accountId)
|
|
50
|
+
context.agentAccountId = accountId;
|
|
51
|
+
if (channel && to) {
|
|
52
|
+
context.currentChat = {
|
|
53
|
+
channel,
|
|
54
|
+
to,
|
|
55
|
+
...(accountId ? { accountId } : {}),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
return Object.keys(context).length > 0 ? context : undefined;
|
|
59
|
+
}
|
|
33
60
|
function getPath(obj, path) {
|
|
34
61
|
return path.split(".").reduce((acc, part) => acc?.[part], obj);
|
|
35
62
|
}
|
|
@@ -73,6 +100,7 @@ export function createCallbackEnvelope(args) {
|
|
|
73
100
|
const dedupeSeed = getTemplateString(watcher.fire.dedupeKeyTemplate, context) ??
|
|
74
101
|
`${watcher.id}|${watcher.fire.eventName}|${matchedAt}`;
|
|
75
102
|
const dedupeKey = createHash("sha256").update(dedupeSeed).digest("hex");
|
|
103
|
+
const deliveryContext = buildDeliveryContextFromMetadata(watcher);
|
|
76
104
|
return {
|
|
77
105
|
type: "sentinel.callback",
|
|
78
106
|
version: "1",
|
|
@@ -90,6 +118,7 @@ export function createCallbackEnvelope(args) {
|
|
|
90
118
|
...(deadline ? { deadline } : {}),
|
|
91
119
|
},
|
|
92
120
|
...(watcher.fire.sessionGroup ? { hookSessionGroup: watcher.fire.sessionGroup } : {}),
|
|
121
|
+
...(deliveryContext ? { deliveryContext } : {}),
|
|
93
122
|
context: renderedContext ?? summarizePayload(payload),
|
|
94
123
|
payload: truncatePayload(payload),
|
|
95
124
|
deliveryTargets: watcher.deliveryTargets ?? [],
|
package/dist/configSchema.js
CHANGED
|
@@ -11,6 +11,7 @@ const NotificationPayloadModeSchema = Type.Union([
|
|
|
11
11
|
Type.Literal("concise"),
|
|
12
12
|
Type.Literal("debug"),
|
|
13
13
|
]);
|
|
14
|
+
const HookResponseFallbackModeSchema = Type.Union([Type.Literal("none"), Type.Literal("concise")]);
|
|
14
15
|
const ConfigSchema = Type.Object({
|
|
15
16
|
allowedHosts: Type.Array(Type.String()),
|
|
16
17
|
localDispatchBase: Type.String({ minLength: 1 }),
|
|
@@ -19,6 +20,9 @@ const ConfigSchema = Type.Object({
|
|
|
19
20
|
hookSessionPrefix: Type.Optional(Type.String({ minLength: 1 })),
|
|
20
21
|
hookSessionGroup: Type.Optional(Type.String({ minLength: 1 })),
|
|
21
22
|
hookRelayDedupeWindowMs: Type.Optional(Type.Integer({ minimum: 0 })),
|
|
23
|
+
hookResponseTimeoutMs: Type.Optional(Type.Integer({ minimum: 0 })),
|
|
24
|
+
hookResponseFallbackMode: Type.Optional(HookResponseFallbackModeSchema),
|
|
25
|
+
hookResponseDedupeWindowMs: Type.Optional(Type.Integer({ minimum: 0 })),
|
|
22
26
|
stateFilePath: Type.Optional(Type.String()),
|
|
23
27
|
notificationPayloadMode: Type.Optional(NotificationPayloadModeSchema),
|
|
24
28
|
limits: Type.Optional(LimitsSchema),
|
|
@@ -37,6 +41,11 @@ function withDefaults(value) {
|
|
|
37
41
|
: "agent:main:hooks:sentinel",
|
|
38
42
|
hookSessionGroup: typeof value.hookSessionGroup === "string" ? value.hookSessionGroup : undefined,
|
|
39
43
|
hookRelayDedupeWindowMs: typeof value.hookRelayDedupeWindowMs === "number" ? value.hookRelayDedupeWindowMs : 120000,
|
|
44
|
+
hookResponseTimeoutMs: typeof value.hookResponseTimeoutMs === "number" ? value.hookResponseTimeoutMs : 30000,
|
|
45
|
+
hookResponseFallbackMode: value.hookResponseFallbackMode === "none" ? "none" : "concise",
|
|
46
|
+
hookResponseDedupeWindowMs: typeof value.hookResponseDedupeWindowMs === "number"
|
|
47
|
+
? value.hookResponseDedupeWindowMs
|
|
48
|
+
: 120000,
|
|
40
49
|
stateFilePath: typeof value.stateFilePath === "string" ? value.stateFilePath : undefined,
|
|
41
50
|
notificationPayloadMode: value.notificationPayloadMode === "none"
|
|
42
51
|
? "none"
|
|
@@ -132,6 +141,24 @@ export const sentinelConfigSchema = {
|
|
|
132
141
|
description: "Suppress duplicate relay messages for the same dedupe key within this window (milliseconds)",
|
|
133
142
|
default: 120000,
|
|
134
143
|
},
|
|
144
|
+
hookResponseTimeoutMs: {
|
|
145
|
+
type: "number",
|
|
146
|
+
minimum: 0,
|
|
147
|
+
description: "Milliseconds to wait for an assistant-authored hook response before optional fallback relay",
|
|
148
|
+
default: 30000,
|
|
149
|
+
},
|
|
150
|
+
hookResponseFallbackMode: {
|
|
151
|
+
type: "string",
|
|
152
|
+
enum: ["none", "concise"],
|
|
153
|
+
description: "Fallback behavior when no assistant response arrives before hookResponseTimeoutMs: none (silent timeout) or concise fail-safe relay",
|
|
154
|
+
default: "concise",
|
|
155
|
+
},
|
|
156
|
+
hookResponseDedupeWindowMs: {
|
|
157
|
+
type: "number",
|
|
158
|
+
minimum: 0,
|
|
159
|
+
description: "Deduplicate hook response-delivery contracts by dedupe key within this window (milliseconds)",
|
|
160
|
+
default: 120000,
|
|
161
|
+
},
|
|
135
162
|
stateFilePath: {
|
|
136
163
|
type: "string",
|
|
137
164
|
description: "Custom path for the sentinel state persistence file",
|
|
@@ -206,6 +233,21 @@ export const sentinelConfigSchema = {
|
|
|
206
233
|
help: "Suppress duplicate relay messages with the same dedupe key for this many milliseconds",
|
|
207
234
|
advanced: true,
|
|
208
235
|
},
|
|
236
|
+
hookResponseTimeoutMs: {
|
|
237
|
+
label: "Hook Response Timeout (ms)",
|
|
238
|
+
help: "How long to wait for assistant-authored hook output before optional fallback relay",
|
|
239
|
+
advanced: true,
|
|
240
|
+
},
|
|
241
|
+
hookResponseFallbackMode: {
|
|
242
|
+
label: "Hook Response Fallback Mode",
|
|
243
|
+
help: "If timeout occurs, choose none (silent) or concise fail-safe relay",
|
|
244
|
+
advanced: true,
|
|
245
|
+
},
|
|
246
|
+
hookResponseDedupeWindowMs: {
|
|
247
|
+
label: "Hook Response Dedupe Window (ms)",
|
|
248
|
+
help: "Deduplicate hook-response delivery contracts by dedupe key within this window",
|
|
249
|
+
advanced: true,
|
|
250
|
+
},
|
|
209
251
|
stateFilePath: {
|
|
210
252
|
label: "State File Path",
|
|
211
253
|
help: "Custom path for sentinel state persistence file",
|
package/dist/index.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { sentinelConfigSchema } from "./configSchema.js";
|
|
3
3
|
import { registerSentinelControl } from "./tool.js";
|
|
4
|
-
import { DEFAULT_SENTINEL_WEBHOOK_PATH } from "./types.js";
|
|
4
|
+
import { DEFAULT_SENTINEL_WEBHOOK_PATH, } from "./types.js";
|
|
5
5
|
import { WatcherManager } from "./watcherManager.js";
|
|
6
6
|
const registeredWebhookPathsByRegistrar = new WeakMap();
|
|
7
7
|
const DEFAULT_HOOK_SESSION_PREFIX = "agent:main:hooks:sentinel";
|
|
8
8
|
const DEFAULT_RELAY_DEDUPE_WINDOW_MS = 120_000;
|
|
9
|
+
const DEFAULT_HOOK_RESPONSE_TIMEOUT_MS = 30_000;
|
|
10
|
+
const DEFAULT_HOOK_RESPONSE_FALLBACK_MODE = "concise";
|
|
9
11
|
const MAX_SENTINEL_WEBHOOK_BODY_BYTES = 64 * 1024;
|
|
10
12
|
const MAX_SENTINEL_WEBHOOK_TEXT_CHARS = 8000;
|
|
11
13
|
const MAX_SENTINEL_PAYLOAD_JSON_CHARS = 2500;
|
|
@@ -98,6 +100,39 @@ function getNestedString(value, path) {
|
|
|
98
100
|
}
|
|
99
101
|
return asString(cursor);
|
|
100
102
|
}
|
|
103
|
+
function extractDeliveryContext(payload) {
|
|
104
|
+
const raw = isRecord(payload.deliveryContext) ? payload.deliveryContext : undefined;
|
|
105
|
+
if (!raw)
|
|
106
|
+
return undefined;
|
|
107
|
+
const sessionKey = asString(raw.sessionKey) ??
|
|
108
|
+
asString(raw.sourceSessionKey) ??
|
|
109
|
+
getNestedString(raw, ["source", "sessionKey"]);
|
|
110
|
+
const messageChannel = asString(raw.messageChannel);
|
|
111
|
+
const requesterSenderId = asString(raw.requesterSenderId);
|
|
112
|
+
const agentAccountId = asString(raw.agentAccountId);
|
|
113
|
+
const currentChat = isDeliveryTarget(raw.currentChat)
|
|
114
|
+
? raw.currentChat
|
|
115
|
+
: isDeliveryTarget(raw.deliveryTarget)
|
|
116
|
+
? raw.deliveryTarget
|
|
117
|
+
: undefined;
|
|
118
|
+
const deliveryTargets = Array.isArray(raw.deliveryTargets)
|
|
119
|
+
? raw.deliveryTargets.filter(isDeliveryTarget)
|
|
120
|
+
: undefined;
|
|
121
|
+
const context = {};
|
|
122
|
+
if (sessionKey)
|
|
123
|
+
context.sessionKey = sessionKey;
|
|
124
|
+
if (messageChannel)
|
|
125
|
+
context.messageChannel = messageChannel;
|
|
126
|
+
if (requesterSenderId)
|
|
127
|
+
context.requesterSenderId = requesterSenderId;
|
|
128
|
+
if (agentAccountId)
|
|
129
|
+
context.agentAccountId = agentAccountId;
|
|
130
|
+
if (currentChat)
|
|
131
|
+
context.currentChat = currentChat;
|
|
132
|
+
if (deliveryTargets && deliveryTargets.length > 0)
|
|
133
|
+
context.deliveryTargets = deliveryTargets;
|
|
134
|
+
return Object.keys(context).length > 0 ? context : undefined;
|
|
135
|
+
}
|
|
101
136
|
function buildSentinelEventEnvelope(payload) {
|
|
102
137
|
const watcherId = asString(payload.watcherId) ??
|
|
103
138
|
getNestedString(payload, ["watcher", "id"]) ??
|
|
@@ -136,6 +171,7 @@ function buildSentinelEventEnvelope(payload) {
|
|
|
136
171
|
const hookSessionGroup = asString(payload.hookSessionGroup) ??
|
|
137
172
|
asString(payload.sessionGroup) ??
|
|
138
173
|
getNestedString(payload, ["watcher", "sessionGroup"]);
|
|
174
|
+
const deliveryContext = extractDeliveryContext(payload);
|
|
139
175
|
const envelope = {
|
|
140
176
|
watcherId: watcherId ?? null,
|
|
141
177
|
eventName: eventName ?? null,
|
|
@@ -154,6 +190,8 @@ function buildSentinelEventEnvelope(payload) {
|
|
|
154
190
|
envelope.hookSessionGroup = hookSessionGroup;
|
|
155
191
|
if (deliveryTargets && deliveryTargets.length > 0)
|
|
156
192
|
envelope.deliveryTargets = deliveryTargets;
|
|
193
|
+
if (deliveryContext)
|
|
194
|
+
envelope.deliveryContext = deliveryContext;
|
|
157
195
|
return envelope;
|
|
158
196
|
}
|
|
159
197
|
function buildSentinelSystemEvent(envelope) {
|
|
@@ -192,10 +230,30 @@ function inferTargetFromSessionKey(sessionKey, accountId) {
|
|
|
192
230
|
};
|
|
193
231
|
}
|
|
194
232
|
function inferRelayTargets(payload, envelope) {
|
|
195
|
-
if (envelope.deliveryTargets?.length) {
|
|
196
|
-
return normalizeDeliveryTargets(envelope.deliveryTargets);
|
|
197
|
-
}
|
|
198
233
|
const inferred = [];
|
|
234
|
+
if (envelope.deliveryTargets?.length)
|
|
235
|
+
inferred.push(...envelope.deliveryTargets);
|
|
236
|
+
if (envelope.deliveryContext?.deliveryTargets?.length) {
|
|
237
|
+
inferred.push(...envelope.deliveryContext.deliveryTargets);
|
|
238
|
+
}
|
|
239
|
+
if (envelope.deliveryContext?.currentChat)
|
|
240
|
+
inferred.push(envelope.deliveryContext.currentChat);
|
|
241
|
+
if (envelope.deliveryContext?.messageChannel && envelope.deliveryContext?.requesterSenderId) {
|
|
242
|
+
if (SUPPORTED_DELIVERY_CHANNELS.has(envelope.deliveryContext.messageChannel)) {
|
|
243
|
+
inferred.push({
|
|
244
|
+
channel: envelope.deliveryContext.messageChannel,
|
|
245
|
+
to: envelope.deliveryContext.requesterSenderId,
|
|
246
|
+
...(envelope.deliveryContext.agentAccountId
|
|
247
|
+
? { accountId: envelope.deliveryContext.agentAccountId }
|
|
248
|
+
: {}),
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
if (envelope.deliveryContext?.sessionKey) {
|
|
253
|
+
const target = inferTargetFromSessionKey(envelope.deliveryContext.sessionKey, envelope.deliveryContext.agentAccountId);
|
|
254
|
+
if (target)
|
|
255
|
+
inferred.push(target);
|
|
256
|
+
}
|
|
199
257
|
if (isDeliveryTarget(payload.currentChat))
|
|
200
258
|
inferred.push(payload.currentChat);
|
|
201
259
|
const sourceCurrentChat = isRecord(payload.source) ? payload.source.currentChat : undefined;
|
|
@@ -247,6 +305,27 @@ function buildRelayMessage(envelope) {
|
|
|
247
305
|
const text = lines.join("\n").trim();
|
|
248
306
|
return text.length > 0 ? text : "Sentinel callback received.";
|
|
249
307
|
}
|
|
308
|
+
function normalizeAssistantRelayText(assistantTexts) {
|
|
309
|
+
if (!Array.isArray(assistantTexts) || assistantTexts.length === 0)
|
|
310
|
+
return undefined;
|
|
311
|
+
const parts = assistantTexts.map((value) => value.trim()).filter(Boolean);
|
|
312
|
+
if (parts.length === 0)
|
|
313
|
+
return undefined;
|
|
314
|
+
return trimText(parts.join("\n\n"), MAX_SENTINEL_WEBHOOK_TEXT_CHARS);
|
|
315
|
+
}
|
|
316
|
+
function resolveHookResponseDedupeWindowMs(config) {
|
|
317
|
+
const candidate = config.hookResponseDedupeWindowMs ??
|
|
318
|
+
config.hookRelayDedupeWindowMs ??
|
|
319
|
+
DEFAULT_RELAY_DEDUPE_WINDOW_MS;
|
|
320
|
+
return Math.max(0, candidate);
|
|
321
|
+
}
|
|
322
|
+
function resolveHookResponseTimeoutMs(config) {
|
|
323
|
+
const candidate = config.hookResponseTimeoutMs ?? DEFAULT_HOOK_RESPONSE_TIMEOUT_MS;
|
|
324
|
+
return Math.max(0, candidate);
|
|
325
|
+
}
|
|
326
|
+
function resolveHookResponseFallbackMode(config) {
|
|
327
|
+
return config.hookResponseFallbackMode === "none" ? "none" : DEFAULT_HOOK_RESPONSE_FALLBACK_MODE;
|
|
328
|
+
}
|
|
250
329
|
function buildIsolatedHookSessionKey(envelope, config) {
|
|
251
330
|
const rawPrefix = asString(config.hookSessionKey) ??
|
|
252
331
|
asString(config.hookSessionPrefix) ??
|
|
@@ -336,6 +415,166 @@ async function notifyDeliveryTarget(api, target, message) {
|
|
|
336
415
|
throw new Error(`Unsupported delivery target channel: ${target.channel}`);
|
|
337
416
|
}
|
|
338
417
|
}
|
|
418
|
+
async function deliverMessageToTargets(api, targets, message) {
|
|
419
|
+
if (targets.length === 0)
|
|
420
|
+
return { delivered: 0, failed: 0 };
|
|
421
|
+
const results = await Promise.all(targets.map(async (target) => {
|
|
422
|
+
try {
|
|
423
|
+
await notifyDeliveryTarget(api, target, message);
|
|
424
|
+
return true;
|
|
425
|
+
}
|
|
426
|
+
catch {
|
|
427
|
+
return false;
|
|
428
|
+
}
|
|
429
|
+
}));
|
|
430
|
+
const delivered = results.filter(Boolean).length;
|
|
431
|
+
return {
|
|
432
|
+
delivered,
|
|
433
|
+
failed: results.length - delivered,
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
class HookResponseRelayManager {
|
|
437
|
+
config;
|
|
438
|
+
api;
|
|
439
|
+
recentByDedupe = new Map();
|
|
440
|
+
pendingByDedupe = new Map();
|
|
441
|
+
pendingQueueBySession = new Map();
|
|
442
|
+
constructor(config, api) {
|
|
443
|
+
this.config = config;
|
|
444
|
+
this.api = api;
|
|
445
|
+
}
|
|
446
|
+
register(args) {
|
|
447
|
+
const dedupeWindowMs = resolveHookResponseDedupeWindowMs(this.config);
|
|
448
|
+
const now = Date.now();
|
|
449
|
+
if (dedupeWindowMs > 0) {
|
|
450
|
+
for (const [key, ts] of this.recentByDedupe.entries()) {
|
|
451
|
+
if (now - ts > dedupeWindowMs) {
|
|
452
|
+
this.recentByDedupe.delete(key);
|
|
453
|
+
this.pendingByDedupe.delete(key);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
const existingTs = this.recentByDedupe.get(args.dedupeKey);
|
|
458
|
+
if (dedupeWindowMs > 0 &&
|
|
459
|
+
typeof existingTs === "number" &&
|
|
460
|
+
now - existingTs <= dedupeWindowMs) {
|
|
461
|
+
return {
|
|
462
|
+
dedupeKey: args.dedupeKey,
|
|
463
|
+
attempted: args.relayTargets.length,
|
|
464
|
+
delivered: 0,
|
|
465
|
+
failed: 0,
|
|
466
|
+
deduped: true,
|
|
467
|
+
pending: false,
|
|
468
|
+
timeoutMs: resolveHookResponseTimeoutMs(this.config),
|
|
469
|
+
fallbackMode: resolveHookResponseFallbackMode(this.config),
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
this.recentByDedupe.set(args.dedupeKey, now);
|
|
473
|
+
const timeoutMs = resolveHookResponseTimeoutMs(this.config);
|
|
474
|
+
const fallbackMode = resolveHookResponseFallbackMode(this.config);
|
|
475
|
+
if (args.relayTargets.length === 0) {
|
|
476
|
+
return {
|
|
477
|
+
dedupeKey: args.dedupeKey,
|
|
478
|
+
attempted: 0,
|
|
479
|
+
delivered: 0,
|
|
480
|
+
failed: 0,
|
|
481
|
+
deduped: false,
|
|
482
|
+
pending: false,
|
|
483
|
+
timeoutMs,
|
|
484
|
+
fallbackMode,
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
const pending = {
|
|
488
|
+
dedupeKey: args.dedupeKey,
|
|
489
|
+
sessionKey: args.sessionKey,
|
|
490
|
+
relayTargets: args.relayTargets,
|
|
491
|
+
fallbackMessage: args.fallbackMessage,
|
|
492
|
+
createdAt: now,
|
|
493
|
+
timeoutMs,
|
|
494
|
+
fallbackMode,
|
|
495
|
+
state: "pending",
|
|
496
|
+
};
|
|
497
|
+
this.pendingByDedupe.set(args.dedupeKey, pending);
|
|
498
|
+
const queue = this.pendingQueueBySession.get(args.sessionKey) ?? [];
|
|
499
|
+
queue.push(args.dedupeKey);
|
|
500
|
+
this.pendingQueueBySession.set(args.sessionKey, queue);
|
|
501
|
+
if (timeoutMs === 0) {
|
|
502
|
+
void this.handleTimeout(args.dedupeKey);
|
|
503
|
+
}
|
|
504
|
+
else {
|
|
505
|
+
pending.timer = setTimeout(() => {
|
|
506
|
+
void this.handleTimeout(args.dedupeKey);
|
|
507
|
+
}, timeoutMs);
|
|
508
|
+
}
|
|
509
|
+
return {
|
|
510
|
+
dedupeKey: args.dedupeKey,
|
|
511
|
+
attempted: args.relayTargets.length,
|
|
512
|
+
delivered: 0,
|
|
513
|
+
failed: 0,
|
|
514
|
+
deduped: false,
|
|
515
|
+
pending: true,
|
|
516
|
+
timeoutMs,
|
|
517
|
+
fallbackMode,
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
async handleLlmOutput(sessionKey, assistantTexts) {
|
|
521
|
+
if (!sessionKey)
|
|
522
|
+
return;
|
|
523
|
+
const assistantMessage = normalizeAssistantRelayText(assistantTexts);
|
|
524
|
+
if (!assistantMessage)
|
|
525
|
+
return;
|
|
526
|
+
const dedupeKey = this.popNextPendingDedupe(sessionKey);
|
|
527
|
+
if (!dedupeKey)
|
|
528
|
+
return;
|
|
529
|
+
const pending = this.pendingByDedupe.get(dedupeKey);
|
|
530
|
+
if (!pending || pending.state !== "pending")
|
|
531
|
+
return;
|
|
532
|
+
await this.completeWithMessage(pending, assistantMessage, "assistant");
|
|
533
|
+
}
|
|
534
|
+
popNextPendingDedupe(sessionKey) {
|
|
535
|
+
const queue = this.pendingQueueBySession.get(sessionKey);
|
|
536
|
+
if (!queue || queue.length === 0)
|
|
537
|
+
return undefined;
|
|
538
|
+
while (queue.length > 0) {
|
|
539
|
+
const next = queue.shift();
|
|
540
|
+
if (!next)
|
|
541
|
+
continue;
|
|
542
|
+
const pending = this.pendingByDedupe.get(next);
|
|
543
|
+
if (pending && pending.state === "pending") {
|
|
544
|
+
if (queue.length === 0)
|
|
545
|
+
this.pendingQueueBySession.delete(sessionKey);
|
|
546
|
+
else
|
|
547
|
+
this.pendingQueueBySession.set(sessionKey, queue);
|
|
548
|
+
return next;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
this.pendingQueueBySession.delete(sessionKey);
|
|
552
|
+
return undefined;
|
|
553
|
+
}
|
|
554
|
+
async handleTimeout(dedupeKey) {
|
|
555
|
+
const pending = this.pendingByDedupe.get(dedupeKey);
|
|
556
|
+
if (!pending || pending.state !== "pending")
|
|
557
|
+
return;
|
|
558
|
+
if (pending.fallbackMode === "none") {
|
|
559
|
+
this.markClosed(pending, "timed_out");
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
await this.completeWithMessage(pending, pending.fallbackMessage, "timeout");
|
|
563
|
+
}
|
|
564
|
+
async completeWithMessage(pending, message, source) {
|
|
565
|
+
const delivery = await deliverMessageToTargets(this.api, pending.relayTargets, message);
|
|
566
|
+
this.markClosed(pending, source === "assistant" ? "completed" : "timed_out");
|
|
567
|
+
this.api.logger?.info?.(`[openclaw-sentinel] ${source === "assistant" ? "Relayed assistant response" : "Sent timeout fallback"} for dedupe=${pending.dedupeKey} delivered=${delivery.delivered} failed=${delivery.failed}`);
|
|
568
|
+
}
|
|
569
|
+
markClosed(pending, state) {
|
|
570
|
+
pending.state = state;
|
|
571
|
+
if (pending.timer) {
|
|
572
|
+
clearTimeout(pending.timer);
|
|
573
|
+
pending.timer = undefined;
|
|
574
|
+
}
|
|
575
|
+
this.pendingByDedupe.set(pending.dedupeKey, pending);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
339
578
|
export function createSentinelPlugin(overrides) {
|
|
340
579
|
const config = {
|
|
341
580
|
allowedHosts: [],
|
|
@@ -343,6 +582,9 @@ export function createSentinelPlugin(overrides) {
|
|
|
343
582
|
dispatchAuthToken: process.env.SENTINEL_DISPATCH_TOKEN,
|
|
344
583
|
hookSessionPrefix: DEFAULT_HOOK_SESSION_PREFIX,
|
|
345
584
|
hookRelayDedupeWindowMs: DEFAULT_RELAY_DEDUPE_WINDOW_MS,
|
|
585
|
+
hookResponseTimeoutMs: DEFAULT_HOOK_RESPONSE_TIMEOUT_MS,
|
|
586
|
+
hookResponseFallbackMode: DEFAULT_HOOK_RESPONSE_FALLBACK_MODE,
|
|
587
|
+
hookResponseDedupeWindowMs: DEFAULT_RELAY_DEDUPE_WINDOW_MS,
|
|
346
588
|
notificationPayloadMode: "concise",
|
|
347
589
|
limits: {
|
|
348
590
|
maxWatchersTotal: 200,
|
|
@@ -352,22 +594,6 @@ export function createSentinelPlugin(overrides) {
|
|
|
352
594
|
},
|
|
353
595
|
...overrides,
|
|
354
596
|
};
|
|
355
|
-
const recentRelayByDedupe = new Map();
|
|
356
|
-
const shouldRelayForDedupe = (dedupeKey) => {
|
|
357
|
-
const windowMs = Math.max(0, config.hookRelayDedupeWindowMs ?? DEFAULT_RELAY_DEDUPE_WINDOW_MS);
|
|
358
|
-
if (windowMs === 0)
|
|
359
|
-
return true;
|
|
360
|
-
const now = Date.now();
|
|
361
|
-
for (const [key, ts] of recentRelayByDedupe.entries()) {
|
|
362
|
-
if (now - ts > windowMs)
|
|
363
|
-
recentRelayByDedupe.delete(key);
|
|
364
|
-
}
|
|
365
|
-
const prev = recentRelayByDedupe.get(dedupeKey);
|
|
366
|
-
if (typeof prev === "number" && now - prev <= windowMs)
|
|
367
|
-
return false;
|
|
368
|
-
recentRelayByDedupe.set(dedupeKey, now);
|
|
369
|
-
return true;
|
|
370
|
-
};
|
|
371
597
|
const manager = new WatcherManager(config, {
|
|
372
598
|
async dispatch(path, body) {
|
|
373
599
|
const headers = { "content-type": "application/json" };
|
|
@@ -389,6 +615,12 @@ export function createSentinelPlugin(overrides) {
|
|
|
389
615
|
const runtimeConfig = resolveSentinelPluginConfig(api);
|
|
390
616
|
if (Object.keys(runtimeConfig).length > 0)
|
|
391
617
|
Object.assign(config, runtimeConfig);
|
|
618
|
+
const hookResponseRelayManager = new HookResponseRelayManager(config, api);
|
|
619
|
+
if (typeof api.on === "function") {
|
|
620
|
+
api.on("llm_output", (event, ctx) => {
|
|
621
|
+
void hookResponseRelayManager.handleLlmOutput(ctx?.sessionKey, event.assistantTexts);
|
|
622
|
+
});
|
|
623
|
+
}
|
|
392
624
|
manager.setNotifier({
|
|
393
625
|
async notify(target, message) {
|
|
394
626
|
await notifyDeliveryTarget(api, target, message);
|
|
@@ -432,30 +664,12 @@ export function createSentinelPlugin(overrides) {
|
|
|
432
664
|
sessionKey,
|
|
433
665
|
});
|
|
434
666
|
const relayTargets = inferRelayTargets(payload, envelope);
|
|
435
|
-
const
|
|
436
|
-
const relay = {
|
|
667
|
+
const relay = hookResponseRelayManager.register({
|
|
437
668
|
dedupeKey: envelope.dedupeKey,
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
};
|
|
443
|
-
if (relayTargets.length > 0) {
|
|
444
|
-
if (!shouldRelayForDedupe(envelope.dedupeKey)) {
|
|
445
|
-
relay.deduped = true;
|
|
446
|
-
}
|
|
447
|
-
else {
|
|
448
|
-
await Promise.all(relayTargets.map(async (target) => {
|
|
449
|
-
try {
|
|
450
|
-
await notifyDeliveryTarget(api, target, relayMessage);
|
|
451
|
-
relay.delivered += 1;
|
|
452
|
-
}
|
|
453
|
-
catch {
|
|
454
|
-
relay.failed += 1;
|
|
455
|
-
}
|
|
456
|
-
}));
|
|
457
|
-
}
|
|
458
|
-
}
|
|
669
|
+
sessionKey,
|
|
670
|
+
relayTargets,
|
|
671
|
+
fallbackMessage: buildRelayMessage(envelope),
|
|
672
|
+
});
|
|
459
673
|
res.writeHead(200, { "content-type": "application/json" });
|
|
460
674
|
res.end(JSON.stringify({
|
|
461
675
|
ok: true,
|
package/dist/tool.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { jsonResult } from "openclaw/plugin-sdk";
|
|
2
2
|
import { Value } from "@sinclair/typebox/value";
|
|
3
|
+
import { SENTINEL_ORIGIN_ACCOUNT_METADATA, SENTINEL_ORIGIN_CHANNEL_METADATA, SENTINEL_ORIGIN_SESSION_KEY_METADATA, SENTINEL_ORIGIN_TARGET_METADATA, } from "./types.js";
|
|
3
4
|
import { SentinelToolSchema, SentinelToolValidationSchema } from "./toolSchema.js";
|
|
4
5
|
import { TemplateValueSchema } from "./templateValueSchema.js";
|
|
5
6
|
function validateParams(params) {
|
|
@@ -63,6 +64,30 @@ function inferDefaultDeliveryTargets(ctx) {
|
|
|
63
64
|
}
|
|
64
65
|
return [];
|
|
65
66
|
}
|
|
67
|
+
function maybeSetMetadata(metadata, key, value) {
|
|
68
|
+
const trimmed = value?.trim();
|
|
69
|
+
if (!trimmed)
|
|
70
|
+
return;
|
|
71
|
+
if (!metadata[key])
|
|
72
|
+
metadata[key] = trimmed;
|
|
73
|
+
}
|
|
74
|
+
function addOriginDeliveryMetadata(watcher, ctx) {
|
|
75
|
+
const metadataRaw = watcher.metadata;
|
|
76
|
+
const metadata = metadataRaw && typeof metadataRaw === "object" && !Array.isArray(metadataRaw)
|
|
77
|
+
? { ...metadataRaw }
|
|
78
|
+
: {};
|
|
79
|
+
const sessionPeer = ctx.sessionKey?.split(":").at(-1)?.trim();
|
|
80
|
+
maybeSetMetadata(metadata, SENTINEL_ORIGIN_SESSION_KEY_METADATA, ctx.sessionKey);
|
|
81
|
+
maybeSetMetadata(metadata, SENTINEL_ORIGIN_CHANNEL_METADATA, ctx.messageChannel);
|
|
82
|
+
maybeSetMetadata(metadata, SENTINEL_ORIGIN_TARGET_METADATA, ctx.requesterSenderId ?? sessionPeer);
|
|
83
|
+
maybeSetMetadata(metadata, SENTINEL_ORIGIN_ACCOUNT_METADATA, ctx.agentAccountId);
|
|
84
|
+
if (Object.keys(metadata).length === 0)
|
|
85
|
+
return watcher;
|
|
86
|
+
return {
|
|
87
|
+
...watcher,
|
|
88
|
+
metadata,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
66
91
|
export function registerSentinelControl(registerTool, manager) {
|
|
67
92
|
registerTool((ctx) => ({
|
|
68
93
|
name: "sentinel_control",
|
|
@@ -73,10 +98,12 @@ export function registerSentinelControl(registerTool, manager) {
|
|
|
73
98
|
const payload = validateParams(params);
|
|
74
99
|
switch (payload.action) {
|
|
75
100
|
case "create":
|
|
76
|
-
case "add":
|
|
77
|
-
|
|
101
|
+
case "add": {
|
|
102
|
+
const watcherWithContext = addOriginDeliveryMetadata(payload.watcher, ctx);
|
|
103
|
+
return normalizeToolResultText(await manager.create(watcherWithContext, {
|
|
78
104
|
deliveryTargets: inferDefaultDeliveryTargets(ctx),
|
|
79
105
|
}), "Watcher created");
|
|
106
|
+
}
|
|
80
107
|
case "enable":
|
|
81
108
|
await manager.enable(payload.id);
|
|
82
109
|
return normalizeToolResultText(undefined, `Enabled watcher: ${payload.id}`);
|
package/dist/types.d.ts
CHANGED
|
@@ -9,6 +9,11 @@ export declare const DEFAULT_SENTINEL_WEBHOOK_PATH = "/hooks/sentinel";
|
|
|
9
9
|
export type PriorityLevel = "low" | "normal" | "high" | "critical";
|
|
10
10
|
export type NotificationPayloadMode = "none" | "concise" | "debug";
|
|
11
11
|
export type NotificationPayloadModeOverride = "inherit" | NotificationPayloadMode;
|
|
12
|
+
export type HookResponseFallbackMode = "none" | "concise";
|
|
13
|
+
export declare const SENTINEL_ORIGIN_SESSION_KEY_METADATA = "openclaw.sentinel.origin.sessionKey";
|
|
14
|
+
export declare const SENTINEL_ORIGIN_CHANNEL_METADATA = "openclaw.sentinel.origin.channel";
|
|
15
|
+
export declare const SENTINEL_ORIGIN_TARGET_METADATA = "openclaw.sentinel.origin.to";
|
|
16
|
+
export declare const SENTINEL_ORIGIN_ACCOUNT_METADATA = "openclaw.sentinel.origin.accountId";
|
|
12
17
|
export interface FireConfig {
|
|
13
18
|
webhookPath?: string;
|
|
14
19
|
eventName: string;
|
|
@@ -92,6 +97,9 @@ export interface SentinelConfig {
|
|
|
92
97
|
hookSessionPrefix?: string;
|
|
93
98
|
hookSessionGroup?: string;
|
|
94
99
|
hookRelayDedupeWindowMs?: number;
|
|
100
|
+
hookResponseTimeoutMs?: number;
|
|
101
|
+
hookResponseFallbackMode?: HookResponseFallbackMode;
|
|
102
|
+
hookResponseDedupeWindowMs?: number;
|
|
95
103
|
stateFilePath?: string;
|
|
96
104
|
notificationPayloadMode?: NotificationPayloadMode;
|
|
97
105
|
limits: SentinelLimits;
|
package/dist/types.js
CHANGED
|
@@ -1 +1,5 @@
|
|
|
1
1
|
export const DEFAULT_SENTINEL_WEBHOOK_PATH = "/hooks/sentinel";
|
|
2
|
+
export const SENTINEL_ORIGIN_SESSION_KEY_METADATA = "openclaw.sentinel.origin.sessionKey";
|
|
3
|
+
export const SENTINEL_ORIGIN_CHANNEL_METADATA = "openclaw.sentinel.origin.channel";
|
|
4
|
+
export const SENTINEL_ORIGIN_TARGET_METADATA = "openclaw.sentinel.origin.to";
|
|
5
|
+
export const SENTINEL_ORIGIN_ACCOUNT_METADATA = "openclaw.sentinel.origin.accountId";
|
package/openclaw.plugin.json
CHANGED
|
@@ -6,7 +6,9 @@
|
|
|
6
6
|
"properties": {
|
|
7
7
|
"allowedHosts": {
|
|
8
8
|
"type": "array",
|
|
9
|
-
"items": {
|
|
9
|
+
"items": {
|
|
10
|
+
"type": "string"
|
|
11
|
+
},
|
|
10
12
|
"description": "Hostnames the watchers are permitted to connect to. Must be explicitly configured — no hosts are allowed by default.",
|
|
11
13
|
"default": []
|
|
12
14
|
},
|
|
@@ -74,6 +76,24 @@
|
|
|
74
76
|
"default": 1000
|
|
75
77
|
}
|
|
76
78
|
}
|
|
79
|
+
},
|
|
80
|
+
"hookResponseTimeoutMs": {
|
|
81
|
+
"type": "number",
|
|
82
|
+
"minimum": 0,
|
|
83
|
+
"description": "Milliseconds to wait for an assistant-authored hook response before optional fallback relay",
|
|
84
|
+
"default": 30000
|
|
85
|
+
},
|
|
86
|
+
"hookResponseFallbackMode": {
|
|
87
|
+
"type": "string",
|
|
88
|
+
"enum": ["none", "concise"],
|
|
89
|
+
"description": "Fallback behavior when no assistant response arrives before hookResponseTimeoutMs: none (silent timeout) or concise fail-safe relay",
|
|
90
|
+
"default": "concise"
|
|
91
|
+
},
|
|
92
|
+
"hookResponseDedupeWindowMs": {
|
|
93
|
+
"type": "number",
|
|
94
|
+
"minimum": 0,
|
|
95
|
+
"description": "Deduplicate hook response-delivery contracts by dedupe key within this window (milliseconds)",
|
|
96
|
+
"default": 120000
|
|
77
97
|
}
|
|
78
98
|
}
|
|
79
99
|
},
|
|
@@ -141,6 +161,21 @@
|
|
|
141
161
|
"label": "Min Poll Interval (ms)",
|
|
142
162
|
"help": "Minimum allowed polling interval in milliseconds",
|
|
143
163
|
"advanced": true
|
|
164
|
+
},
|
|
165
|
+
"hookResponseTimeoutMs": {
|
|
166
|
+
"label": "Hook Response Timeout (ms)",
|
|
167
|
+
"help": "How long to wait for assistant-authored hook output before optional fallback relay",
|
|
168
|
+
"advanced": true
|
|
169
|
+
},
|
|
170
|
+
"hookResponseFallbackMode": {
|
|
171
|
+
"label": "Hook Response Fallback Mode",
|
|
172
|
+
"help": "If timeout occurs, choose none (silent) or concise fail-safe relay",
|
|
173
|
+
"advanced": true
|
|
174
|
+
},
|
|
175
|
+
"hookResponseDedupeWindowMs": {
|
|
176
|
+
"label": "Hook Response Dedupe Window (ms)",
|
|
177
|
+
"help": "Deduplicate hook-response delivery contracts by dedupe key within this window",
|
|
178
|
+
"advanced": true
|
|
144
179
|
}
|
|
145
180
|
},
|
|
146
181
|
"install": {
|