@botcord/daemon 0.2.78 → 0.2.80
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/dist/attention-policy-fetcher.d.ts +14 -0
- package/dist/attention-policy-fetcher.js +59 -0
- package/dist/cloud-daemon.js +8 -0
- package/dist/cloud-gateway-runtime.d.ts +29 -0
- package/dist/cloud-gateway-runtime.js +122 -0
- package/dist/daemon-singleton.d.ts +13 -0
- package/dist/daemon-singleton.js +68 -0
- package/dist/daemon.js +21 -6
- package/dist/gateway/channels/botcord.d.ts +1 -0
- package/dist/gateway/channels/botcord.js +62 -17
- package/dist/gateway/channels/login-session.d.ts +12 -0
- package/dist/gateway/channels/login-session.js +20 -2
- package/dist/gateway/channels/sanitize.d.ts +5 -18
- package/dist/gateway/channels/sanitize.js +5 -54
- package/dist/gateway/channels/text-split.d.ts +5 -11
- package/dist/gateway/channels/text-split.js +5 -31
- package/dist/gateway/dispatcher.d.ts +7 -1
- package/dist/gateway/dispatcher.js +88 -8
- package/dist/gateway/gateway.d.ts +16 -1
- package/dist/gateway/gateway.js +21 -0
- package/dist/gateway/policy-resolver.js +17 -9
- package/dist/gateway/runtimes/deepseek-tui.js +56 -13
- package/dist/gateway/types.d.ts +12 -57
- package/dist/gateway-control.js +18 -9
- package/dist/index.js +8 -3
- package/dist/provision.d.ts +7 -3
- package/dist/provision.js +115 -8
- package/dist/room-recovery-context.d.ts +11 -0
- package/dist/room-recovery-context.js +97 -0
- package/dist/status-render.d.ts +4 -0
- package/dist/status-render.js +14 -1
- package/package.json +2 -2
- package/src/__tests__/attention-policy-fetcher.test.ts +67 -0
- package/src/__tests__/cloud-gateway-runtime.test.ts +127 -0
- package/src/__tests__/daemon-singleton.test.ts +32 -0
- package/src/__tests__/gateway-control.test.ts +136 -0
- package/src/__tests__/policy-resolver.test.ts +20 -0
- package/src/__tests__/provision.test.ts +65 -0
- package/src/__tests__/status-render.test.ts +23 -0
- package/src/attention-policy-fetcher.ts +87 -0
- package/src/cloud-daemon.ts +8 -0
- package/src/cloud-gateway-runtime.ts +171 -0
- package/src/daemon-singleton.ts +85 -0
- package/src/daemon.ts +23 -6
- package/src/gateway/__tests__/botcord-channel.test.ts +211 -5
- package/src/gateway/__tests__/deepseek-tui-adapter.test.ts +263 -0
- package/src/gateway/__tests__/dispatcher.test.ts +56 -0
- package/src/gateway/channels/botcord.ts +69 -17
- package/src/gateway/channels/login-session.ts +20 -2
- package/src/gateway/channels/sanitize.ts +8 -66
- package/src/gateway/channels/text-split.ts +5 -27
- package/src/gateway/dispatcher.ts +123 -27
- package/src/gateway/gateway.ts +29 -0
- package/src/gateway/policy-resolver.ts +20 -9
- package/src/gateway/runtimes/deepseek-tui.ts +63 -13
- package/src/gateway/types.ts +31 -59
- package/src/gateway-control.ts +21 -9
- package/src/index.ts +9 -2
- package/src/provision.ts +133 -7
- package/src/room-recovery-context.ts +131 -0
- package/src/status-render.ts +14 -1
|
@@ -35,10 +35,28 @@ export class LoginSessionStore {
|
|
|
35
35
|
this.sessions.set(session.loginId, session);
|
|
36
36
|
return session;
|
|
37
37
|
}
|
|
38
|
+
/**
|
|
39
|
+
* Distinguish whether `loginId` is unknown to the store ("missing") vs
|
|
40
|
+
* known-but-past-TTL ("expired"). When the entry is expired this also
|
|
41
|
+
* evicts it from the internal map so callers do not need to follow up
|
|
42
|
+
* with a separate `delete`. Use this when the caller wants to surface
|
|
43
|
+
* a precise error code to the user; prefer `get` when a single nullable
|
|
44
|
+
* result is enough.
|
|
45
|
+
*/
|
|
46
|
+
resolve(loginId) {
|
|
47
|
+
const s = this.sessions.get(loginId);
|
|
48
|
+
if (!s)
|
|
49
|
+
return { state: "missing" };
|
|
50
|
+
if (s.expiresAt <= this.now()) {
|
|
51
|
+
this.sessions.delete(loginId);
|
|
52
|
+
return { state: "expired" };
|
|
53
|
+
}
|
|
54
|
+
return { state: "live", session: s };
|
|
55
|
+
}
|
|
38
56
|
/** Get a non-expired session by id, or `null` when missing/expired. */
|
|
39
57
|
get(loginId) {
|
|
40
|
-
this.
|
|
41
|
-
return
|
|
58
|
+
const { state, session } = this.resolve(loginId);
|
|
59
|
+
return state === "live" && session ? session : null;
|
|
42
60
|
}
|
|
43
61
|
/**
|
|
44
62
|
* Apply a partial patch to the session in place. No-op when the session
|
|
@@ -1,20 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* any new structural marker added in one place should be mirrored in the other.
|
|
7
|
-
*
|
|
8
|
-
* Neutralizes:
|
|
9
|
-
* - BotCord structural markers the channel itself emits (so peers can't forge them).
|
|
10
|
-
* - Common LLM prompt-injection patterns (<system>, [INST], <<SYS>>, <|im_start|>, etc.).
|
|
11
|
-
* - Wrapper XML tags the channel uses to frame inbound content
|
|
12
|
-
* (<agent-message>, <human-message>, <room-rule>).
|
|
2
|
+
* Thin re-export — `sanitizeUntrustedContent` / `sanitizeSenderName` live
|
|
3
|
+
* in `@botcord/protocol-core` so the daemon channel adapters and the
|
|
4
|
+
* `gateway-ingress` provider adapters use one canonical implementation.
|
|
5
|
+
* Existing imports of this module keep working unchanged.
|
|
13
6
|
*/
|
|
14
|
-
export
|
|
15
|
-
/**
|
|
16
|
-
* Sanitize a sender label so it's safe to embed inside
|
|
17
|
-
* `<agent-message sender="...">`. Must not contain newlines, structural
|
|
18
|
-
* markers, or characters that could break the XML attribute boundary.
|
|
19
|
-
*/
|
|
20
|
-
export declare function sanitizeSenderName(name: string): string;
|
|
7
|
+
export { sanitizeUntrustedContent, sanitizeSenderName, } from "@botcord/protocol-core";
|
|
@@ -1,56 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* any new structural marker added in one place should be mirrored in the other.
|
|
7
|
-
*
|
|
8
|
-
* Neutralizes:
|
|
9
|
-
* - BotCord structural markers the channel itself emits (so peers can't forge them).
|
|
10
|
-
* - Common LLM prompt-injection patterns (<system>, [INST], <<SYS>>, <|im_start|>, etc.).
|
|
11
|
-
* - Wrapper XML tags the channel uses to frame inbound content
|
|
12
|
-
* (<agent-message>, <human-message>, <room-rule>).
|
|
2
|
+
* Thin re-export — `sanitizeUntrustedContent` / `sanitizeSenderName` live
|
|
3
|
+
* in `@botcord/protocol-core` so the daemon channel adapters and the
|
|
4
|
+
* `gateway-ingress` provider adapters use one canonical implementation.
|
|
5
|
+
* Existing imports of this module keep working unchanged.
|
|
13
6
|
*/
|
|
14
|
-
export
|
|
15
|
-
let s = text;
|
|
16
|
-
s = s.replace(/<\/?a[\s]*g[\s]*e[\s]*n[\s]*t[\s]*-[\s]*m[\s]*e[\s]*s[\s]*s[\s]*a[\s]*g[\s]*e[\s\S]*?>/gi, "[⚠ stripped: agent-message tag]");
|
|
17
|
-
s = s.replace(/<\/?h[\s]*u[\s]*m[\s]*a[\s]*n[\s]*-[\s]*m[\s]*e[\s]*s[\s]*s[\s]*a[\s]*g[\s]*e[\s\S]*?>/gi, "[⚠ stripped: human-message tag]");
|
|
18
|
-
s = s.replace(/<\/?r[\s]*o[\s]*o[\s]*m[\s]*-[\s]*r[\s]*u[\s]*l[\s]*e[\s\S]*?>/gi, "[⚠ stripped: room-rule tag]");
|
|
19
|
-
return s
|
|
20
|
-
.split(/\r?\n/)
|
|
21
|
-
.map((line) => {
|
|
22
|
-
let l = line;
|
|
23
|
-
l = l.replace(/^\[(BotCord (?:Message|Notification))\]/i, "[⚠ fake: $1]");
|
|
24
|
-
l = l.replace(/^\[Room Rule\]/i, "[⚠ fake: Room Rule]");
|
|
25
|
-
l = l.replace(/^\[房间规则\]/i, "[⚠ fake: 房间规则]");
|
|
26
|
-
l = l.replace(/^\[系统提示\]/i, "[⚠ fake: 系统提示]");
|
|
27
|
-
l = l.replace(/^\[BotCord\s+([^\]\r\n]+)\]/i, (_m, label) => {
|
|
28
|
-
const head = String(label).split(":")[0].trim() || String(label).trim();
|
|
29
|
-
return `[⚠ fake: BotCord ${head}]`;
|
|
30
|
-
});
|
|
31
|
-
l = l.replace(/^\[(System|SYSTEM|Assistant|ASSISTANT|User|USER)\]/, "[⚠ fake: $1]");
|
|
32
|
-
l = l.replace(/<\/?\s*system(?:-reminder)?\s*>/gi, "[⚠ stripped: system tag]");
|
|
33
|
-
l = l.replace(/<\|im_start\|>/gi, "[⚠ stripped: im_start]");
|
|
34
|
-
l = l.replace(/<\|im_end\|>/gi, "[⚠ stripped: im_end]");
|
|
35
|
-
l = l.replace(/\[\/?INST\]/gi, "[⚠ stripped: INST]");
|
|
36
|
-
l = l.replace(/<<\/?SYS>>/gi, "[⚠ stripped: SYS]");
|
|
37
|
-
l = l.replace(/<\s*\/?\|(?:system|user|assistant)\|?\s*>/gi, "[⚠ stripped: role tag]");
|
|
38
|
-
return l;
|
|
39
|
-
})
|
|
40
|
-
.join("\n");
|
|
41
|
-
}
|
|
42
|
-
/**
|
|
43
|
-
* Sanitize a sender label so it's safe to embed inside
|
|
44
|
-
* `<agent-message sender="...">`. Must not contain newlines, structural
|
|
45
|
-
* markers, or characters that could break the XML attribute boundary.
|
|
46
|
-
*/
|
|
47
|
-
export function sanitizeSenderName(name) {
|
|
48
|
-
return name
|
|
49
|
-
.replace(/[\n\r]/g, " ")
|
|
50
|
-
.replace(/\[/g, "⟦")
|
|
51
|
-
.replace(/\]/g, "⟧")
|
|
52
|
-
.replace(/"/g, "'")
|
|
53
|
-
.replace(/</g, "<")
|
|
54
|
-
.replace(/>/g, ">")
|
|
55
|
-
.slice(0, 100);
|
|
56
|
-
}
|
|
7
|
+
export { sanitizeUntrustedContent, sanitizeSenderName, } from "@botcord/protocol-core";
|
|
@@ -1,13 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* per-message size cap from upstream and no native streaming. WeChat caller
|
|
7
|
-
* passes a smaller `limit` (~1800), Telegram a larger one (~4000, since the
|
|
8
|
-
* raw Telegram limit is 4096).
|
|
9
|
-
*
|
|
10
|
-
* Empty input returns `[""]` so callers can iterate uniformly without a length
|
|
11
|
-
* check.
|
|
2
|
+
* Thin re-export — `splitText` lives in `@botcord/protocol-core` so the
|
|
3
|
+
* daemon channel adapters and the `gateway-ingress` provider adapters use
|
|
4
|
+
* one canonical implementation. Existing imports of this module keep
|
|
5
|
+
* working unchanged.
|
|
12
6
|
*/
|
|
13
|
-
export
|
|
7
|
+
export { splitText } from "@botcord/protocol-core";
|
|
@@ -1,33 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* per-message size cap from upstream and no native streaming. WeChat caller
|
|
7
|
-
* passes a smaller `limit` (~1800), Telegram a larger one (~4000, since the
|
|
8
|
-
* raw Telegram limit is 4096).
|
|
9
|
-
*
|
|
10
|
-
* Empty input returns `[""]` so callers can iterate uniformly without a length
|
|
11
|
-
* check.
|
|
2
|
+
* Thin re-export — `splitText` lives in `@botcord/protocol-core` so the
|
|
3
|
+
* daemon channel adapters and the `gateway-ingress` provider adapters use
|
|
4
|
+
* one canonical implementation. Existing imports of this module keep
|
|
5
|
+
* working unchanged.
|
|
12
6
|
*/
|
|
13
|
-
export
|
|
14
|
-
if (limit <= 0)
|
|
15
|
-
return [text];
|
|
16
|
-
if (text.length === 0)
|
|
17
|
-
return [""];
|
|
18
|
-
if (text.length <= limit)
|
|
19
|
-
return [text];
|
|
20
|
-
const out = [];
|
|
21
|
-
let remaining = text;
|
|
22
|
-
while (remaining.length > limit) {
|
|
23
|
-
let cut = remaining.lastIndexOf("\n", limit);
|
|
24
|
-
if (cut <= 0)
|
|
25
|
-
cut = limit;
|
|
26
|
-
out.push(remaining.slice(0, cut));
|
|
27
|
-
// Drop the leading newline so the next chunk doesn't start with a blank line.
|
|
28
|
-
remaining = remaining.slice(cut).replace(/^\n/, "");
|
|
29
|
-
}
|
|
30
|
-
if (remaining.length > 0)
|
|
31
|
-
out.push(remaining);
|
|
32
|
-
return out;
|
|
33
|
-
}
|
|
7
|
+
export { splitText } from "@botcord/protocol-core";
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { GatewayLogger } from "./log.js";
|
|
2
2
|
import { type SessionStore } from "./session-store.js";
|
|
3
3
|
import { type TranscriptWriter } from "./transcript.js";
|
|
4
|
-
import type { ChannelAdapter, GatewayConfig, GatewayInboundEnvelope, GatewayInboundMessage, GatewayRoute, InboundObserver, MemoryContextBuilder, OutboundObserver, RuntimeAdapter, RuntimeRunResult, RuntimeCircuitBreakerSnapshot, SystemContextBuilder, TurnStatusSnapshot, UserTurnBuilder } from "./types.js";
|
|
4
|
+
import type { ChannelAdapter, GatewayConfig, GatewayInboundEnvelope, GatewayInboundMessage, GatewayRoute, InboundObserver, MemoryContextBuilder, OutboundObserver, RuntimeAdapter, RuntimeRecoveryContextBuilder, RuntimeRunResult, RuntimeCircuitBreakerSnapshot, SystemContextBuilder, TurnStatusSnapshot, UserTurnBuilder } from "./types.js";
|
|
5
5
|
/** Factory signature for building a runtime adapter at turn dispatch time. */
|
|
6
6
|
export type RuntimeFactory = (runtimeId: string, extraArgs?: string[]) => RuntimeAdapter;
|
|
7
7
|
/** Constructor options for `Dispatcher`. */
|
|
@@ -33,6 +33,11 @@ export interface DispatcherOptions {
|
|
|
33
33
|
* keep following stale memory.
|
|
34
34
|
*/
|
|
35
35
|
buildMemoryContext?: MemoryContextBuilder;
|
|
36
|
+
/**
|
|
37
|
+
* Optional hook that returns recent room context for a fresh-session retry
|
|
38
|
+
* after a runtime resume session becomes unrecoverable.
|
|
39
|
+
*/
|
|
40
|
+
buildRuntimeRecoveryContext?: RuntimeRecoveryContextBuilder;
|
|
36
41
|
/**
|
|
37
42
|
* Optional side-effect hook invoked after ack, before the turn runs.
|
|
38
43
|
* Intended for bookkeeping (e.g. activity tracking). Errors are logged
|
|
@@ -116,6 +121,7 @@ export declare class Dispatcher {
|
|
|
116
121
|
private readonly runtimeAuthFailureCooldownMs;
|
|
117
122
|
private readonly buildSystemContext?;
|
|
118
123
|
private readonly buildMemoryContext?;
|
|
124
|
+
private readonly buildRuntimeRecoveryContext?;
|
|
119
125
|
private readonly onInbound?;
|
|
120
126
|
private readonly onOutbound?;
|
|
121
127
|
private readonly onTurnComplete?;
|
|
@@ -124,6 +124,25 @@ function extractCloudRunBudget(msg) {
|
|
|
124
124
|
}
|
|
125
125
|
return out.maxWallTimeMs !== undefined || out.maxToolCalls !== undefined ? out : undefined;
|
|
126
126
|
}
|
|
127
|
+
function looksLikeRecoverableSessionFailure(error) {
|
|
128
|
+
return /compact|compaction|context|token limit|maximum context|too many tokens|conversation found|session .*not found|resume/i
|
|
129
|
+
.test(error);
|
|
130
|
+
}
|
|
131
|
+
function buildRuntimeRecoveryPrompt(args) {
|
|
132
|
+
return [
|
|
133
|
+
"[BotCord Runtime Recovery Notice]",
|
|
134
|
+
"The previous Codex runtime session for this room became unrecoverable while resuming or compacting context.",
|
|
135
|
+
`Previous runtime error: ${truncate(args.error, 1000)}`,
|
|
136
|
+
"You are now running in a fresh Codex session.",
|
|
137
|
+
"Use the recent room messages below, current filesystem state, and available BotCord memory/context tools to reconstruct the active task.",
|
|
138
|
+
"Continue the original user request without asking the user to repeat information unless it is missing from those sources.",
|
|
139
|
+
"",
|
|
140
|
+
args.recoveryContext?.trim() || "[Recent Room Messages]\n(unavailable)",
|
|
141
|
+
"",
|
|
142
|
+
"[Current User Turn]",
|
|
143
|
+
args.userTurn,
|
|
144
|
+
].join("\n");
|
|
145
|
+
}
|
|
127
146
|
/**
|
|
128
147
|
* Reason carried on `AbortController.abort()` when a cancel-previous wave
|
|
129
148
|
* is taking over the slot. Distinguishing this from a timeout abort lets
|
|
@@ -164,6 +183,7 @@ export class Dispatcher {
|
|
|
164
183
|
runtimeAuthFailureCooldownMs;
|
|
165
184
|
buildSystemContext;
|
|
166
185
|
buildMemoryContext;
|
|
186
|
+
buildRuntimeRecoveryContext;
|
|
167
187
|
onInbound;
|
|
168
188
|
onOutbound;
|
|
169
189
|
onTurnComplete;
|
|
@@ -195,6 +215,7 @@ export class Dispatcher {
|
|
|
195
215
|
opts.runtimeAuthFailureCooldownMs ?? DEFAULT_RUNTIME_AUTH_FAILURE_COOLDOWN_MS;
|
|
196
216
|
this.buildSystemContext = opts.buildSystemContext;
|
|
197
217
|
this.buildMemoryContext = opts.buildMemoryContext;
|
|
218
|
+
this.buildRuntimeRecoveryContext = opts.buildRuntimeRecoveryContext;
|
|
198
219
|
this.onInbound = opts.onInbound;
|
|
199
220
|
this.onOutbound = opts.onOutbound;
|
|
200
221
|
this.onTurnComplete = opts.onTurnComplete;
|
|
@@ -1269,12 +1290,13 @@ export class Dispatcher {
|
|
|
1269
1290
|
const runtime = this.runtimeFactory(route.runtime, route.extraArgs);
|
|
1270
1291
|
let result;
|
|
1271
1292
|
let threw;
|
|
1293
|
+
let activeSessionId = sessionId;
|
|
1272
1294
|
const turnStartedAt = Date.now();
|
|
1273
1295
|
try {
|
|
1274
1296
|
try {
|
|
1275
|
-
|
|
1276
|
-
text:
|
|
1277
|
-
sessionId,
|
|
1297
|
+
const runRuntime = (textForRun, sessionIdForRun) => runtime.run({
|
|
1298
|
+
text: textForRun,
|
|
1299
|
+
sessionId: sessionIdForRun,
|
|
1278
1300
|
cwd: route.cwd,
|
|
1279
1301
|
accountId: msg.accountId,
|
|
1280
1302
|
hubUrl: this.resolveHubUrl?.(msg.accountId),
|
|
@@ -1296,6 +1318,64 @@ export class Dispatcher {
|
|
|
1296
1318
|
gateway: route.gateway,
|
|
1297
1319
|
...(route.hermesProfile ? { hermesProfile: route.hermesProfile } : {}),
|
|
1298
1320
|
});
|
|
1321
|
+
result = await runRuntime(runtimeText, sessionId);
|
|
1322
|
+
const firstError = result.error ?? "";
|
|
1323
|
+
const firstReply = (result.text || "").trim();
|
|
1324
|
+
const shouldRetryFresh = route.runtime === "codex" &&
|
|
1325
|
+
!!sessionId &&
|
|
1326
|
+
!!firstError &&
|
|
1327
|
+
!firstReply &&
|
|
1328
|
+
!looksLikeRuntimeAuthFailure(firstError) &&
|
|
1329
|
+
looksLikeRecoverableSessionFailure(firstError) &&
|
|
1330
|
+
!controller.signal.aborted &&
|
|
1331
|
+
!slot.timedOut &&
|
|
1332
|
+
!slot.budgetExceeded;
|
|
1333
|
+
if (shouldRetryFresh) {
|
|
1334
|
+
try {
|
|
1335
|
+
await this.sessionStore.delete(key);
|
|
1336
|
+
this.log.info("dispatcher: dropped unrecoverable runtime session before fresh retry", {
|
|
1337
|
+
key,
|
|
1338
|
+
prevRuntimeSessionId: sessionId,
|
|
1339
|
+
runtime: route.runtime,
|
|
1340
|
+
error: firstError,
|
|
1341
|
+
});
|
|
1342
|
+
}
|
|
1343
|
+
catch (err) {
|
|
1344
|
+
this.log.warn("dispatcher: session-store.delete failed before fresh retry", {
|
|
1345
|
+
key,
|
|
1346
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1347
|
+
});
|
|
1348
|
+
}
|
|
1349
|
+
let recoveryContext;
|
|
1350
|
+
if (this.buildRuntimeRecoveryContext) {
|
|
1351
|
+
try {
|
|
1352
|
+
recoveryContext = await this.buildRuntimeRecoveryContext(msg);
|
|
1353
|
+
}
|
|
1354
|
+
catch (err) {
|
|
1355
|
+
this.log.warn("dispatcher: buildRuntimeRecoveryContext threw — retrying without recent room context", {
|
|
1356
|
+
agentId: msg.accountId,
|
|
1357
|
+
roomId: msg.conversation.id,
|
|
1358
|
+
topicId: msg.conversation.threadId ?? null,
|
|
1359
|
+
turnId,
|
|
1360
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1361
|
+
});
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
activeSessionId = null;
|
|
1365
|
+
runtimeText = buildRuntimeRecoveryPrompt({
|
|
1366
|
+
userTurn: text,
|
|
1367
|
+
error: firstError,
|
|
1368
|
+
recoveryContext,
|
|
1369
|
+
});
|
|
1370
|
+
this.log.info("dispatcher: retrying codex turn in a fresh session with recovery context", {
|
|
1371
|
+
agentId: msg.accountId,
|
|
1372
|
+
roomId: msg.conversation.id,
|
|
1373
|
+
topicId: msg.conversation.threadId ?? null,
|
|
1374
|
+
turnId,
|
|
1375
|
+
queueKey,
|
|
1376
|
+
});
|
|
1377
|
+
result = await runRuntime(runtimeText, null);
|
|
1378
|
+
}
|
|
1299
1379
|
}
|
|
1300
1380
|
catch (err) {
|
|
1301
1381
|
threw = err;
|
|
@@ -1477,12 +1557,12 @@ export class Dispatcher {
|
|
|
1477
1557
|
// even when the adapter echoes that id back
|
|
1478
1558
|
// result.newSessionId truthy → upsert the entry
|
|
1479
1559
|
// otherwise → no-op (e.g. codex intentionally never persists)
|
|
1480
|
-
if (
|
|
1560
|
+
if (activeSessionId && effectiveError && !replyText) {
|
|
1481
1561
|
try {
|
|
1482
1562
|
await this.sessionStore.delete(key);
|
|
1483
1563
|
this.log.info("dispatcher: dropped stale runtime session", {
|
|
1484
1564
|
key,
|
|
1485
|
-
prevRuntimeSessionId:
|
|
1565
|
+
prevRuntimeSessionId: activeSessionId,
|
|
1486
1566
|
nextRuntimeSessionId: result.newSessionId || null,
|
|
1487
1567
|
error: effectiveError,
|
|
1488
1568
|
});
|
|
@@ -1509,7 +1589,7 @@ export class Dispatcher {
|
|
|
1509
1589
|
updatedAt: Date.now(),
|
|
1510
1590
|
};
|
|
1511
1591
|
try {
|
|
1512
|
-
const prevRuntimeSessionId =
|
|
1592
|
+
const prevRuntimeSessionId = activeSessionId;
|
|
1513
1593
|
await this.sessionStore.set(session);
|
|
1514
1594
|
this.log.debug("dispatcher: persisted runtime session", {
|
|
1515
1595
|
key,
|
|
@@ -1524,12 +1604,12 @@ export class Dispatcher {
|
|
|
1524
1604
|
});
|
|
1525
1605
|
}
|
|
1526
1606
|
}
|
|
1527
|
-
else if (
|
|
1607
|
+
else if (activeSessionId && effectiveError) {
|
|
1528
1608
|
try {
|
|
1529
1609
|
await this.sessionStore.delete(key);
|
|
1530
1610
|
this.log.info("dispatcher: dropped stale runtime session", {
|
|
1531
1611
|
key,
|
|
1532
|
-
prevRuntimeSessionId:
|
|
1612
|
+
prevRuntimeSessionId: activeSessionId,
|
|
1533
1613
|
error: effectiveError,
|
|
1534
1614
|
});
|
|
1535
1615
|
}
|
|
@@ -2,7 +2,7 @@ import { type ChannelBackoffOptions } from "./channel-manager.js";
|
|
|
2
2
|
import { type DispatcherOptions, type RuntimeFactory } from "./dispatcher.js";
|
|
3
3
|
import { type GatewayLogger } from "./log.js";
|
|
4
4
|
import { type TranscriptWriter } from "./transcript.js";
|
|
5
|
-
import type { ChannelAdapter, GatewayChannelConfig, GatewayConfig, GatewayInboundMessage, GatewayOutboundMessage, GatewayRoute, GatewayRuntimeSnapshot, InboundObserver, MemoryContextBuilder, OutboundObserver, SystemContextBuilder, UserTurnBuilder } from "./types.js";
|
|
5
|
+
import type { ChannelAdapter, GatewayChannelConfig, GatewayConfig, GatewayInboundMessage, GatewayOutboundMessage, GatewayRoute, GatewayRuntimeSnapshot, InboundObserver, MemoryContextBuilder, OutboundObserver, RuntimeRecoveryContextBuilder, SystemContextBuilder, UserTurnBuilder } from "./types.js";
|
|
6
6
|
/** Constructor options for `Gateway`. */
|
|
7
7
|
export interface GatewayBootOptions {
|
|
8
8
|
config: GatewayConfig;
|
|
@@ -25,6 +25,11 @@ export interface GatewayBootOptions {
|
|
|
25
25
|
* resumed runtime sessions get an explicit prompt when memory changes.
|
|
26
26
|
*/
|
|
27
27
|
buildMemoryContext?: MemoryContextBuilder;
|
|
28
|
+
/**
|
|
29
|
+
* Recent room context provider used by dispatcher when it must discard a
|
|
30
|
+
* broken runtime session and retry the same turn in a fresh session.
|
|
31
|
+
*/
|
|
32
|
+
buildRuntimeRecoveryContext?: RuntimeRecoveryContextBuilder;
|
|
28
33
|
/**
|
|
29
34
|
* Observer called after the dispatcher acks each inbound message. Useful
|
|
30
35
|
* for activity tracking or metrics. Errors are logged and swallowed.
|
|
@@ -137,6 +142,16 @@ export declare class Gateway {
|
|
|
137
142
|
* routing, queueing, transcript, and runtime behavior as channel messages.
|
|
138
143
|
*/
|
|
139
144
|
injectInbound(message: GatewayInboundMessage): Promise<void>;
|
|
145
|
+
/**
|
|
146
|
+
* Inject an inbound message while routing replies through a caller-owned
|
|
147
|
+
* channel adapter. Cloud gateway runtime sessions use this to execute a
|
|
148
|
+
* provider message without loading provider credentials inside the sandbox:
|
|
149
|
+
* the temporary adapter captures the runtime's final reply and the always-on
|
|
150
|
+
* ingress service performs the provider send.
|
|
151
|
+
*/
|
|
152
|
+
injectInboundThrough(message: GatewayInboundMessage, channel: ChannelAdapter, ack?: {
|
|
153
|
+
accept: () => Promise<void>;
|
|
154
|
+
}): Promise<void>;
|
|
140
155
|
/**
|
|
141
156
|
* Send a daemon-control initiated outbound message through a registered
|
|
142
157
|
* channel. Used by proactive third-party gateway sends where the runtime
|
package/dist/gateway/gateway.js
CHANGED
|
@@ -69,6 +69,7 @@ export class Gateway {
|
|
|
69
69
|
turnTimeoutMs: opts.turnTimeoutMs,
|
|
70
70
|
buildSystemContext: opts.buildSystemContext,
|
|
71
71
|
buildMemoryContext: opts.buildMemoryContext,
|
|
72
|
+
buildRuntimeRecoveryContext: opts.buildRuntimeRecoveryContext,
|
|
72
73
|
onInbound: opts.onInbound,
|
|
73
74
|
composeUserTurn: opts.composeUserTurn,
|
|
74
75
|
onOutbound: opts.onOutbound,
|
|
@@ -178,6 +179,26 @@ export class Gateway {
|
|
|
178
179
|
async injectInbound(message) {
|
|
179
180
|
await this.dispatcher.handle({ message });
|
|
180
181
|
}
|
|
182
|
+
/**
|
|
183
|
+
* Inject an inbound message while routing replies through a caller-owned
|
|
184
|
+
* channel adapter. Cloud gateway runtime sessions use this to execute a
|
|
185
|
+
* provider message without loading provider credentials inside the sandbox:
|
|
186
|
+
* the temporary adapter captures the runtime's final reply and the always-on
|
|
187
|
+
* ingress service performs the provider send.
|
|
188
|
+
*/
|
|
189
|
+
async injectInboundThrough(message, channel, ack) {
|
|
190
|
+
const previous = this.channelMap.get(channel.id);
|
|
191
|
+
this.channelMap.set(channel.id, channel);
|
|
192
|
+
try {
|
|
193
|
+
await this.dispatcher.handle({ message, ...(ack ? { ack } : {}) });
|
|
194
|
+
}
|
|
195
|
+
finally {
|
|
196
|
+
if (previous)
|
|
197
|
+
this.channelMap.set(channel.id, previous);
|
|
198
|
+
else
|
|
199
|
+
this.channelMap.delete(channel.id);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
181
202
|
/**
|
|
182
203
|
* Send a daemon-control initiated outbound message through a registered
|
|
183
204
|
* channel. Used by proactive third-party gateway sends where the runtime
|
|
@@ -24,16 +24,24 @@
|
|
|
24
24
|
const DEFAULT_TTL_MS = 5 * 60 * 1000;
|
|
25
25
|
const FETCH_FAILED = Symbol("fetch_failed");
|
|
26
26
|
/**
|
|
27
|
-
* Force
|
|
27
|
+
* Force direct conversations to `mode: "always"` per design §4.2 — UI never
|
|
28
28
|
* lets the user mute a DM, but a stale cache from before a UX bug is cheap
|
|
29
|
-
* to defend against here.
|
|
29
|
+
* to defend against here. Third-party 1:1 gateway chats have the same
|
|
30
|
+
* expectation: they do not carry BotCord mention metadata, so applying a
|
|
31
|
+
* global mention-only policy would silently drop ordinary direct messages.
|
|
30
32
|
*/
|
|
31
|
-
function
|
|
32
|
-
if (roomId && roomId
|
|
33
|
+
function maybeForceDirectConversation(roomId, policy) {
|
|
34
|
+
if (roomId && isDirectConversation(roomId) && policy.mode !== "always") {
|
|
33
35
|
return { ...policy, mode: "always" };
|
|
34
36
|
}
|
|
35
37
|
return policy;
|
|
36
38
|
}
|
|
39
|
+
function isDirectConversation(roomId) {
|
|
40
|
+
return (roomId.startsWith("rm_dm_") ||
|
|
41
|
+
roomId.startsWith("telegram:user:") ||
|
|
42
|
+
roomId.startsWith("wechat:user:") ||
|
|
43
|
+
roomId.startsWith("feishu:user:"));
|
|
44
|
+
}
|
|
37
45
|
function defaultPolicy() {
|
|
38
46
|
return { mode: "always", keywords: [] };
|
|
39
47
|
}
|
|
@@ -65,10 +73,10 @@ export class PolicyResolver {
|
|
|
65
73
|
return defaultPolicy();
|
|
66
74
|
const policy = fetched ?? defaultPolicy();
|
|
67
75
|
this.cache.set(cacheKey(agentId, roomId), {
|
|
68
|
-
policy:
|
|
76
|
+
policy: maybeForceDirectConversation(roomId, policy),
|
|
69
77
|
expiresAt: now + this.ttlMs,
|
|
70
78
|
});
|
|
71
|
-
return
|
|
79
|
+
return maybeForceDirectConversation(roomId, policy);
|
|
72
80
|
}
|
|
73
81
|
// 3. No room override known — inherit from the cached agent-wide global.
|
|
74
82
|
// Without this layer, group messages collapsed to mode=always whenever
|
|
@@ -77,7 +85,7 @@ export class PolicyResolver {
|
|
|
77
85
|
const globalKey = cacheKey(agentId, null);
|
|
78
86
|
const globalHit = this.cache.get(globalKey);
|
|
79
87
|
if (globalHit && globalHit.expiresAt > now) {
|
|
80
|
-
return
|
|
88
|
+
return maybeForceDirectConversation(roomId, globalHit.policy);
|
|
81
89
|
}
|
|
82
90
|
// 4. Cold start for global.
|
|
83
91
|
const fetched = await this.safeFetch(() => this.fetchGlobal(agentId));
|
|
@@ -85,7 +93,7 @@ export class PolicyResolver {
|
|
|
85
93
|
return defaultPolicy();
|
|
86
94
|
const policy = fetched ?? defaultPolicy();
|
|
87
95
|
this.cache.set(globalKey, { policy, expiresAt: now + this.ttlMs });
|
|
88
|
-
return
|
|
96
|
+
return maybeForceDirectConversation(roomId, policy);
|
|
89
97
|
}
|
|
90
98
|
async safeFetch(fn) {
|
|
91
99
|
try {
|
|
@@ -113,7 +121,7 @@ export class PolicyResolver {
|
|
|
113
121
|
put(agentId, roomId, policy) {
|
|
114
122
|
const key = cacheKey(agentId, roomId);
|
|
115
123
|
this.cache.set(key, {
|
|
116
|
-
policy:
|
|
124
|
+
policy: maybeForceDirectConversation(roomId, policy),
|
|
117
125
|
expiresAt: Date.now() + this.ttlMs,
|
|
118
126
|
});
|
|
119
127
|
}
|
|
@@ -97,10 +97,12 @@ export class DeepseekTuiAdapter {
|
|
|
97
97
|
signal: turnAbort.signal,
|
|
98
98
|
});
|
|
99
99
|
const text = runResult.text;
|
|
100
|
+
const error = runResult.error ??
|
|
101
|
+
(text === "" ? emptyCompletionError(handle.stderrTail) : undefined);
|
|
100
102
|
return {
|
|
101
103
|
text,
|
|
102
104
|
newSessionId: threadId,
|
|
103
|
-
...(
|
|
105
|
+
...(error ? { error } : {}),
|
|
104
106
|
};
|
|
105
107
|
}
|
|
106
108
|
catch (err) {
|
|
@@ -316,14 +318,18 @@ export class DeepseekTuiAdapter {
|
|
|
316
318
|
if (eventName === "message.delta") {
|
|
317
319
|
append(stringField(payload, "content") ?? "");
|
|
318
320
|
}
|
|
319
|
-
else if (eventName === "item.delta" && payload
|
|
320
|
-
append(
|
|
321
|
+
else if (eventName === "item.delta" && isAgentMessageDelta(payload)) {
|
|
322
|
+
append(extractDeepseekDelta(payload));
|
|
321
323
|
}
|
|
322
324
|
if (eventName === "turn.started" || embeddedDeepseekEvent(payload) === "turn.started") {
|
|
323
325
|
opts.onStatus?.({ kind: "thinking", phase: "started", label: "Thinking" });
|
|
324
326
|
}
|
|
325
|
-
else if (eventName === "tool.started" || isToolStarted(payload)) {
|
|
326
|
-
const label = stringField(payload, "name") ??
|
|
327
|
+
else if (eventName === "tool.started" || isToolStarted(eventName, payload)) {
|
|
328
|
+
const label = stringField(payload, "name") ??
|
|
329
|
+
stringField(payload?.tool, "name") ??
|
|
330
|
+
stringField(payload?.payload?.tool, "name") ??
|
|
331
|
+
inferDeepseekToolName(payload?.item ?? payload?.payload?.item) ??
|
|
332
|
+
"tool";
|
|
327
333
|
opts.onStatus?.({ kind: "thinking", phase: "updated", label });
|
|
328
334
|
}
|
|
329
335
|
else if (isDeepseekTerminalEvent(eventName, payload)) {
|
|
@@ -385,15 +391,18 @@ function normalizeDeepseekEvent(eventName, payload, seq) {
|
|
|
385
391
|
if (eventName === "message.delta") {
|
|
386
392
|
return { raw: { event: eventName, payload }, kind: "assistant_text", seq };
|
|
387
393
|
}
|
|
388
|
-
if (eventName === "tool.started" || isToolStarted(payload)) {
|
|
394
|
+
if (eventName === "tool.started" || isToolStarted(eventName, payload)) {
|
|
389
395
|
return { raw: { event: eventName, payload }, kind: "tool_use", seq };
|
|
390
396
|
}
|
|
391
|
-
if (eventName === "tool.completed" || isToolCompleted(payload)) {
|
|
397
|
+
if (eventName === "tool.completed" || isToolCompleted(eventName, payload)) {
|
|
392
398
|
return { raw: { event: eventName, payload }, kind: "tool_result", seq };
|
|
393
399
|
}
|
|
394
|
-
if (eventName === "item.delta" && payload
|
|
400
|
+
if (eventName === "item.delta" && isAgentMessageDelta(payload)) {
|
|
395
401
|
return { raw: { event: eventName, payload }, kind: "assistant_text", seq };
|
|
396
402
|
}
|
|
403
|
+
if (eventName === "item.completed" && isAgentReasoningItem(payload)) {
|
|
404
|
+
return { raw: { event: eventName, payload }, kind: "thinking", seq };
|
|
405
|
+
}
|
|
397
406
|
if (eventName === "turn.started" || eventName === "status" || embeddedDeepseekEvent(payload) === "turn.started") {
|
|
398
407
|
return { raw: { event: eventName, payload }, kind: "system", seq };
|
|
399
408
|
}
|
|
@@ -416,14 +425,48 @@ function isDeepseekTerminalEvent(eventName, payload) {
|
|
|
416
425
|
embedded === "turn.done" ||
|
|
417
426
|
embedded === "done");
|
|
418
427
|
}
|
|
419
|
-
function isToolStarted(payload) {
|
|
420
|
-
return
|
|
428
|
+
function isToolStarted(eventName, payload) {
|
|
429
|
+
return ((eventName === "item.started" &&
|
|
430
|
+
(!!payload?.tool || payload?.item?.kind === "tool_call" || payload?.payload?.item?.kind === "tool_call")) ||
|
|
431
|
+
(payload?.event === "item.started" && !!payload?.payload?.tool));
|
|
421
432
|
}
|
|
422
|
-
function isToolCompleted(payload) {
|
|
423
|
-
const kind = payload?.payload?.item?.kind;
|
|
424
|
-
return ((
|
|
433
|
+
function isToolCompleted(eventName, payload) {
|
|
434
|
+
const kind = payload?.payload?.item?.kind ?? payload?.item?.kind;
|
|
435
|
+
return ((eventName === "item.completed" ||
|
|
436
|
+
eventName === "item.failed" ||
|
|
437
|
+
payload?.event === "item.completed" ||
|
|
438
|
+
payload?.event === "item.failed") &&
|
|
425
439
|
(kind === "tool_call" || kind === "file_change" || kind === "command_execution"));
|
|
426
440
|
}
|
|
441
|
+
function isAgentMessageDelta(payload) {
|
|
442
|
+
return payload?.kind === "agent_message" || payload?.payload?.kind === "agent_message";
|
|
443
|
+
}
|
|
444
|
+
function isAgentReasoningItem(payload) {
|
|
445
|
+
return payload?.item?.kind === "agent_reasoning" || payload?.payload?.item?.kind === "agent_reasoning";
|
|
446
|
+
}
|
|
447
|
+
function extractDeepseekDelta(payload) {
|
|
448
|
+
return stringField(payload, "delta") ?? stringField(payload?.payload, "delta") ?? "";
|
|
449
|
+
}
|
|
450
|
+
function inferDeepseekToolName(item) {
|
|
451
|
+
const candidates = [stringField(item, "summary"), stringField(item, "detail")];
|
|
452
|
+
for (const candidate of candidates) {
|
|
453
|
+
if (!candidate)
|
|
454
|
+
continue;
|
|
455
|
+
const match = candidate.match(/^([A-Za-z0-9_.:-]+)\s*(?:started|completed|failed|returned|:)/);
|
|
456
|
+
if (match?.[1] && match[1] !== "tool_call")
|
|
457
|
+
return match[1];
|
|
458
|
+
}
|
|
459
|
+
return undefined;
|
|
460
|
+
}
|
|
461
|
+
function emptyCompletionError(stderrTail) {
|
|
462
|
+
const tail = stderrTail.trim();
|
|
463
|
+
if (!tail) {
|
|
464
|
+
return "deepseek runtime completed with no assistant_message (check DEEPSEEK_API_KEY / model availability)";
|
|
465
|
+
}
|
|
466
|
+
const lines = tail.split(/\r?\n/).filter((line) => line.trim().length > 0);
|
|
467
|
+
const lastLines = lines.slice(-5).join("\n").slice(-500);
|
|
468
|
+
return `deepseek runtime completed with no assistant_message; stderr tail: ${lastLines}`;
|
|
469
|
+
}
|
|
427
470
|
function extractDeepseekError(eventName, payload) {
|
|
428
471
|
if (eventName === "error") {
|
|
429
472
|
return (stringField(payload, "message") ??
|