@coffeexdev/openclaw-sentinel 0.4.5 → 0.5.1
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 +270 -46
- 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 with a cron-tagged callback context, then requests an immediate `cron:sentinel-callback` wake (avoids heartbeat-poll prompting).
|
|
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,14 +1,19 @@
|
|
|
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;
|
|
14
|
+
const SENTINEL_CALLBACK_WAKE_REASON = "cron:sentinel-callback";
|
|
15
|
+
const SENTINEL_CALLBACK_CONTEXT_KEY = "cron:sentinel-callback";
|
|
16
|
+
const HEARTBEAT_ACK_TOKEN_PATTERN = /\bHEARTBEAT_OK\b/gi;
|
|
12
17
|
const SENTINEL_EVENT_INSTRUCTION_PREFIX = "SENTINEL_TRIGGER: This system event came from /hooks/sentinel. Evaluate action policy, decide whether to notify configured deliveryTargets, and execute safe follow-up actions.";
|
|
13
18
|
const SUPPORTED_DELIVERY_CHANNELS = new Set([
|
|
14
19
|
"telegram",
|
|
@@ -98,6 +103,39 @@ function getNestedString(value, path) {
|
|
|
98
103
|
}
|
|
99
104
|
return asString(cursor);
|
|
100
105
|
}
|
|
106
|
+
function extractDeliveryContext(payload) {
|
|
107
|
+
const raw = isRecord(payload.deliveryContext) ? payload.deliveryContext : undefined;
|
|
108
|
+
if (!raw)
|
|
109
|
+
return undefined;
|
|
110
|
+
const sessionKey = asString(raw.sessionKey) ??
|
|
111
|
+
asString(raw.sourceSessionKey) ??
|
|
112
|
+
getNestedString(raw, ["source", "sessionKey"]);
|
|
113
|
+
const messageChannel = asString(raw.messageChannel);
|
|
114
|
+
const requesterSenderId = asString(raw.requesterSenderId);
|
|
115
|
+
const agentAccountId = asString(raw.agentAccountId);
|
|
116
|
+
const currentChat = isDeliveryTarget(raw.currentChat)
|
|
117
|
+
? raw.currentChat
|
|
118
|
+
: isDeliveryTarget(raw.deliveryTarget)
|
|
119
|
+
? raw.deliveryTarget
|
|
120
|
+
: undefined;
|
|
121
|
+
const deliveryTargets = Array.isArray(raw.deliveryTargets)
|
|
122
|
+
? raw.deliveryTargets.filter(isDeliveryTarget)
|
|
123
|
+
: undefined;
|
|
124
|
+
const context = {};
|
|
125
|
+
if (sessionKey)
|
|
126
|
+
context.sessionKey = sessionKey;
|
|
127
|
+
if (messageChannel)
|
|
128
|
+
context.messageChannel = messageChannel;
|
|
129
|
+
if (requesterSenderId)
|
|
130
|
+
context.requesterSenderId = requesterSenderId;
|
|
131
|
+
if (agentAccountId)
|
|
132
|
+
context.agentAccountId = agentAccountId;
|
|
133
|
+
if (currentChat)
|
|
134
|
+
context.currentChat = currentChat;
|
|
135
|
+
if (deliveryTargets && deliveryTargets.length > 0)
|
|
136
|
+
context.deliveryTargets = deliveryTargets;
|
|
137
|
+
return Object.keys(context).length > 0 ? context : undefined;
|
|
138
|
+
}
|
|
101
139
|
function buildSentinelEventEnvelope(payload) {
|
|
102
140
|
const watcherId = asString(payload.watcherId) ??
|
|
103
141
|
getNestedString(payload, ["watcher", "id"]) ??
|
|
@@ -136,6 +174,7 @@ function buildSentinelEventEnvelope(payload) {
|
|
|
136
174
|
const hookSessionGroup = asString(payload.hookSessionGroup) ??
|
|
137
175
|
asString(payload.sessionGroup) ??
|
|
138
176
|
getNestedString(payload, ["watcher", "sessionGroup"]);
|
|
177
|
+
const deliveryContext = extractDeliveryContext(payload);
|
|
139
178
|
const envelope = {
|
|
140
179
|
watcherId: watcherId ?? null,
|
|
141
180
|
eventName: eventName ?? null,
|
|
@@ -154,6 +193,8 @@ function buildSentinelEventEnvelope(payload) {
|
|
|
154
193
|
envelope.hookSessionGroup = hookSessionGroup;
|
|
155
194
|
if (deliveryTargets && deliveryTargets.length > 0)
|
|
156
195
|
envelope.deliveryTargets = deliveryTargets;
|
|
196
|
+
if (deliveryContext)
|
|
197
|
+
envelope.deliveryContext = deliveryContext;
|
|
157
198
|
return envelope;
|
|
158
199
|
}
|
|
159
200
|
function buildSentinelSystemEvent(envelope) {
|
|
@@ -192,10 +233,30 @@ function inferTargetFromSessionKey(sessionKey, accountId) {
|
|
|
192
233
|
};
|
|
193
234
|
}
|
|
194
235
|
function inferRelayTargets(payload, envelope) {
|
|
195
|
-
if (envelope.deliveryTargets?.length) {
|
|
196
|
-
return normalizeDeliveryTargets(envelope.deliveryTargets);
|
|
197
|
-
}
|
|
198
236
|
const inferred = [];
|
|
237
|
+
if (envelope.deliveryTargets?.length)
|
|
238
|
+
inferred.push(...envelope.deliveryTargets);
|
|
239
|
+
if (envelope.deliveryContext?.deliveryTargets?.length) {
|
|
240
|
+
inferred.push(...envelope.deliveryContext.deliveryTargets);
|
|
241
|
+
}
|
|
242
|
+
if (envelope.deliveryContext?.currentChat)
|
|
243
|
+
inferred.push(envelope.deliveryContext.currentChat);
|
|
244
|
+
if (envelope.deliveryContext?.messageChannel && envelope.deliveryContext?.requesterSenderId) {
|
|
245
|
+
if (SUPPORTED_DELIVERY_CHANNELS.has(envelope.deliveryContext.messageChannel)) {
|
|
246
|
+
inferred.push({
|
|
247
|
+
channel: envelope.deliveryContext.messageChannel,
|
|
248
|
+
to: envelope.deliveryContext.requesterSenderId,
|
|
249
|
+
...(envelope.deliveryContext.agentAccountId
|
|
250
|
+
? { accountId: envelope.deliveryContext.agentAccountId }
|
|
251
|
+
: {}),
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
if (envelope.deliveryContext?.sessionKey) {
|
|
256
|
+
const target = inferTargetFromSessionKey(envelope.deliveryContext.sessionKey, envelope.deliveryContext.agentAccountId);
|
|
257
|
+
if (target)
|
|
258
|
+
inferred.push(target);
|
|
259
|
+
}
|
|
199
260
|
if (isDeliveryTarget(payload.currentChat))
|
|
200
261
|
inferred.push(payload.currentChat);
|
|
201
262
|
const sourceCurrentChat = isRecord(payload.source) ? payload.source.currentChat : undefined;
|
|
@@ -245,7 +306,32 @@ function buildRelayMessage(envelope) {
|
|
|
245
306
|
if (contextSummary)
|
|
246
307
|
lines.push(contextSummary);
|
|
247
308
|
const text = lines.join("\n").trim();
|
|
248
|
-
return text.length > 0
|
|
309
|
+
return text.length > 0
|
|
310
|
+
? text
|
|
311
|
+
: "Sentinel callback received, but no assistant detail was generated.";
|
|
312
|
+
}
|
|
313
|
+
function normalizeAssistantRelayText(assistantTexts) {
|
|
314
|
+
if (!Array.isArray(assistantTexts) || assistantTexts.length === 0)
|
|
315
|
+
return undefined;
|
|
316
|
+
const parts = assistantTexts
|
|
317
|
+
.map((value) => value.replace(HEARTBEAT_ACK_TOKEN_PATTERN, "").trim())
|
|
318
|
+
.filter(Boolean);
|
|
319
|
+
if (parts.length === 0)
|
|
320
|
+
return undefined;
|
|
321
|
+
return trimText(parts.join("\n\n"), MAX_SENTINEL_WEBHOOK_TEXT_CHARS);
|
|
322
|
+
}
|
|
323
|
+
function resolveHookResponseDedupeWindowMs(config) {
|
|
324
|
+
const candidate = config.hookResponseDedupeWindowMs ??
|
|
325
|
+
config.hookRelayDedupeWindowMs ??
|
|
326
|
+
DEFAULT_RELAY_DEDUPE_WINDOW_MS;
|
|
327
|
+
return Math.max(0, candidate);
|
|
328
|
+
}
|
|
329
|
+
function resolveHookResponseTimeoutMs(config) {
|
|
330
|
+
const candidate = config.hookResponseTimeoutMs ?? DEFAULT_HOOK_RESPONSE_TIMEOUT_MS;
|
|
331
|
+
return Math.max(0, candidate);
|
|
332
|
+
}
|
|
333
|
+
function resolveHookResponseFallbackMode(config) {
|
|
334
|
+
return config.hookResponseFallbackMode === "none" ? "none" : DEFAULT_HOOK_RESPONSE_FALLBACK_MODE;
|
|
249
335
|
}
|
|
250
336
|
function buildIsolatedHookSessionKey(envelope, config) {
|
|
251
337
|
const rawPrefix = asString(config.hookSessionKey) ??
|
|
@@ -336,6 +422,166 @@ async function notifyDeliveryTarget(api, target, message) {
|
|
|
336
422
|
throw new Error(`Unsupported delivery target channel: ${target.channel}`);
|
|
337
423
|
}
|
|
338
424
|
}
|
|
425
|
+
async function deliverMessageToTargets(api, targets, message) {
|
|
426
|
+
if (targets.length === 0)
|
|
427
|
+
return { delivered: 0, failed: 0 };
|
|
428
|
+
const results = await Promise.all(targets.map(async (target) => {
|
|
429
|
+
try {
|
|
430
|
+
await notifyDeliveryTarget(api, target, message);
|
|
431
|
+
return true;
|
|
432
|
+
}
|
|
433
|
+
catch {
|
|
434
|
+
return false;
|
|
435
|
+
}
|
|
436
|
+
}));
|
|
437
|
+
const delivered = results.filter(Boolean).length;
|
|
438
|
+
return {
|
|
439
|
+
delivered,
|
|
440
|
+
failed: results.length - delivered,
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
class HookResponseRelayManager {
|
|
444
|
+
config;
|
|
445
|
+
api;
|
|
446
|
+
recentByDedupe = new Map();
|
|
447
|
+
pendingByDedupe = new Map();
|
|
448
|
+
pendingQueueBySession = new Map();
|
|
449
|
+
constructor(config, api) {
|
|
450
|
+
this.config = config;
|
|
451
|
+
this.api = api;
|
|
452
|
+
}
|
|
453
|
+
register(args) {
|
|
454
|
+
const dedupeWindowMs = resolveHookResponseDedupeWindowMs(this.config);
|
|
455
|
+
const now = Date.now();
|
|
456
|
+
if (dedupeWindowMs > 0) {
|
|
457
|
+
for (const [key, ts] of this.recentByDedupe.entries()) {
|
|
458
|
+
if (now - ts > dedupeWindowMs) {
|
|
459
|
+
this.recentByDedupe.delete(key);
|
|
460
|
+
this.pendingByDedupe.delete(key);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
const existingTs = this.recentByDedupe.get(args.dedupeKey);
|
|
465
|
+
if (dedupeWindowMs > 0 &&
|
|
466
|
+
typeof existingTs === "number" &&
|
|
467
|
+
now - existingTs <= dedupeWindowMs) {
|
|
468
|
+
return {
|
|
469
|
+
dedupeKey: args.dedupeKey,
|
|
470
|
+
attempted: args.relayTargets.length,
|
|
471
|
+
delivered: 0,
|
|
472
|
+
failed: 0,
|
|
473
|
+
deduped: true,
|
|
474
|
+
pending: false,
|
|
475
|
+
timeoutMs: resolveHookResponseTimeoutMs(this.config),
|
|
476
|
+
fallbackMode: resolveHookResponseFallbackMode(this.config),
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
this.recentByDedupe.set(args.dedupeKey, now);
|
|
480
|
+
const timeoutMs = resolveHookResponseTimeoutMs(this.config);
|
|
481
|
+
const fallbackMode = resolveHookResponseFallbackMode(this.config);
|
|
482
|
+
if (args.relayTargets.length === 0) {
|
|
483
|
+
return {
|
|
484
|
+
dedupeKey: args.dedupeKey,
|
|
485
|
+
attempted: 0,
|
|
486
|
+
delivered: 0,
|
|
487
|
+
failed: 0,
|
|
488
|
+
deduped: false,
|
|
489
|
+
pending: false,
|
|
490
|
+
timeoutMs,
|
|
491
|
+
fallbackMode,
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
const pending = {
|
|
495
|
+
dedupeKey: args.dedupeKey,
|
|
496
|
+
sessionKey: args.sessionKey,
|
|
497
|
+
relayTargets: args.relayTargets,
|
|
498
|
+
fallbackMessage: args.fallbackMessage,
|
|
499
|
+
createdAt: now,
|
|
500
|
+
timeoutMs,
|
|
501
|
+
fallbackMode,
|
|
502
|
+
state: "pending",
|
|
503
|
+
};
|
|
504
|
+
this.pendingByDedupe.set(args.dedupeKey, pending);
|
|
505
|
+
const queue = this.pendingQueueBySession.get(args.sessionKey) ?? [];
|
|
506
|
+
queue.push(args.dedupeKey);
|
|
507
|
+
this.pendingQueueBySession.set(args.sessionKey, queue);
|
|
508
|
+
if (timeoutMs === 0) {
|
|
509
|
+
void this.handleTimeout(args.dedupeKey);
|
|
510
|
+
}
|
|
511
|
+
else {
|
|
512
|
+
pending.timer = setTimeout(() => {
|
|
513
|
+
void this.handleTimeout(args.dedupeKey);
|
|
514
|
+
}, timeoutMs);
|
|
515
|
+
}
|
|
516
|
+
return {
|
|
517
|
+
dedupeKey: args.dedupeKey,
|
|
518
|
+
attempted: args.relayTargets.length,
|
|
519
|
+
delivered: 0,
|
|
520
|
+
failed: 0,
|
|
521
|
+
deduped: false,
|
|
522
|
+
pending: true,
|
|
523
|
+
timeoutMs,
|
|
524
|
+
fallbackMode,
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
async handleLlmOutput(sessionKey, assistantTexts) {
|
|
528
|
+
if (!sessionKey)
|
|
529
|
+
return;
|
|
530
|
+
const assistantMessage = normalizeAssistantRelayText(assistantTexts);
|
|
531
|
+
if (!assistantMessage)
|
|
532
|
+
return;
|
|
533
|
+
const dedupeKey = this.popNextPendingDedupe(sessionKey);
|
|
534
|
+
if (!dedupeKey)
|
|
535
|
+
return;
|
|
536
|
+
const pending = this.pendingByDedupe.get(dedupeKey);
|
|
537
|
+
if (!pending || pending.state !== "pending")
|
|
538
|
+
return;
|
|
539
|
+
await this.completeWithMessage(pending, assistantMessage, "assistant");
|
|
540
|
+
}
|
|
541
|
+
popNextPendingDedupe(sessionKey) {
|
|
542
|
+
const queue = this.pendingQueueBySession.get(sessionKey);
|
|
543
|
+
if (!queue || queue.length === 0)
|
|
544
|
+
return undefined;
|
|
545
|
+
while (queue.length > 0) {
|
|
546
|
+
const next = queue.shift();
|
|
547
|
+
if (!next)
|
|
548
|
+
continue;
|
|
549
|
+
const pending = this.pendingByDedupe.get(next);
|
|
550
|
+
if (pending && pending.state === "pending") {
|
|
551
|
+
if (queue.length === 0)
|
|
552
|
+
this.pendingQueueBySession.delete(sessionKey);
|
|
553
|
+
else
|
|
554
|
+
this.pendingQueueBySession.set(sessionKey, queue);
|
|
555
|
+
return next;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
this.pendingQueueBySession.delete(sessionKey);
|
|
559
|
+
return undefined;
|
|
560
|
+
}
|
|
561
|
+
async handleTimeout(dedupeKey) {
|
|
562
|
+
const pending = this.pendingByDedupe.get(dedupeKey);
|
|
563
|
+
if (!pending || pending.state !== "pending")
|
|
564
|
+
return;
|
|
565
|
+
if (pending.fallbackMode === "none") {
|
|
566
|
+
this.markClosed(pending, "timed_out");
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
await this.completeWithMessage(pending, pending.fallbackMessage, "timeout");
|
|
570
|
+
}
|
|
571
|
+
async completeWithMessage(pending, message, source) {
|
|
572
|
+
const delivery = await deliverMessageToTargets(this.api, pending.relayTargets, message);
|
|
573
|
+
this.markClosed(pending, source === "assistant" ? "completed" : "timed_out");
|
|
574
|
+
this.api.logger?.info?.(`[openclaw-sentinel] ${source === "assistant" ? "Relayed assistant response" : "Sent timeout fallback"} for dedupe=${pending.dedupeKey} delivered=${delivery.delivered} failed=${delivery.failed}`);
|
|
575
|
+
}
|
|
576
|
+
markClosed(pending, state) {
|
|
577
|
+
pending.state = state;
|
|
578
|
+
if (pending.timer) {
|
|
579
|
+
clearTimeout(pending.timer);
|
|
580
|
+
pending.timer = undefined;
|
|
581
|
+
}
|
|
582
|
+
this.pendingByDedupe.set(pending.dedupeKey, pending);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
339
585
|
export function createSentinelPlugin(overrides) {
|
|
340
586
|
const config = {
|
|
341
587
|
allowedHosts: [],
|
|
@@ -343,6 +589,9 @@ export function createSentinelPlugin(overrides) {
|
|
|
343
589
|
dispatchAuthToken: process.env.SENTINEL_DISPATCH_TOKEN,
|
|
344
590
|
hookSessionPrefix: DEFAULT_HOOK_SESSION_PREFIX,
|
|
345
591
|
hookRelayDedupeWindowMs: DEFAULT_RELAY_DEDUPE_WINDOW_MS,
|
|
592
|
+
hookResponseTimeoutMs: DEFAULT_HOOK_RESPONSE_TIMEOUT_MS,
|
|
593
|
+
hookResponseFallbackMode: DEFAULT_HOOK_RESPONSE_FALLBACK_MODE,
|
|
594
|
+
hookResponseDedupeWindowMs: DEFAULT_RELAY_DEDUPE_WINDOW_MS,
|
|
346
595
|
notificationPayloadMode: "concise",
|
|
347
596
|
limits: {
|
|
348
597
|
maxWatchersTotal: 200,
|
|
@@ -352,22 +601,6 @@ export function createSentinelPlugin(overrides) {
|
|
|
352
601
|
},
|
|
353
602
|
...overrides,
|
|
354
603
|
};
|
|
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
604
|
const manager = new WatcherManager(config, {
|
|
372
605
|
async dispatch(path, body) {
|
|
373
606
|
const headers = { "content-type": "application/json" };
|
|
@@ -389,6 +622,12 @@ export function createSentinelPlugin(overrides) {
|
|
|
389
622
|
const runtimeConfig = resolveSentinelPluginConfig(api);
|
|
390
623
|
if (Object.keys(runtimeConfig).length > 0)
|
|
391
624
|
Object.assign(config, runtimeConfig);
|
|
625
|
+
const hookResponseRelayManager = new HookResponseRelayManager(config, api);
|
|
626
|
+
if (typeof api.on === "function") {
|
|
627
|
+
api.on("llm_output", (event, ctx) => {
|
|
628
|
+
void hookResponseRelayManager.handleLlmOutput(ctx?.sessionKey, event.assistantTexts);
|
|
629
|
+
});
|
|
630
|
+
}
|
|
392
631
|
manager.setNotifier({
|
|
393
632
|
async notify(target, message) {
|
|
394
633
|
await notifyDeliveryTarget(api, target, message);
|
|
@@ -426,36 +665,21 @@ export function createSentinelPlugin(overrides) {
|
|
|
426
665
|
const envelope = buildSentinelEventEnvelope(payload);
|
|
427
666
|
const sessionKey = buildIsolatedHookSessionKey(envelope, config);
|
|
428
667
|
const text = buildSentinelSystemEvent(envelope);
|
|
429
|
-
const enqueued = api.runtime.system.enqueueSystemEvent(text, {
|
|
668
|
+
const enqueued = api.runtime.system.enqueueSystemEvent(text, {
|
|
669
|
+
sessionKey,
|
|
670
|
+
contextKey: SENTINEL_CALLBACK_CONTEXT_KEY,
|
|
671
|
+
});
|
|
430
672
|
api.runtime.system.requestHeartbeatNow({
|
|
431
|
-
reason:
|
|
673
|
+
reason: SENTINEL_CALLBACK_WAKE_REASON,
|
|
432
674
|
sessionKey,
|
|
433
675
|
});
|
|
434
676
|
const relayTargets = inferRelayTargets(payload, envelope);
|
|
435
|
-
const
|
|
436
|
-
const relay = {
|
|
677
|
+
const relay = hookResponseRelayManager.register({
|
|
437
678
|
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
|
-
}
|
|
679
|
+
sessionKey,
|
|
680
|
+
relayTargets,
|
|
681
|
+
fallbackMessage: buildRelayMessage(envelope),
|
|
682
|
+
});
|
|
459
683
|
res.writeHead(200, { "content-type": "application/json" });
|
|
460
684
|
res.end(JSON.stringify({
|
|
461
685
|
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": {
|