@botcord/daemon 0.2.77 → 0.2.79
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/agent-discovery.d.ts +6 -0
- package/dist/agent-discovery.js +6 -0
- 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-config-map.d.ts +6 -0
- package/dist/daemon-config-map.js +5 -4
- package/dist/daemon.d.ts +3 -0
- package/dist/daemon.js +32 -7
- package/dist/gateway/channels/botcord.js +29 -9
- 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 +86 -19
- package/dist/gateway/types.d.ts +12 -57
- package/dist/gateway-control.js +18 -9
- package/dist/provision.d.ts +9 -3
- package/dist/provision.js +181 -9
- package/dist/room-recovery-context.d.ts +11 -0
- package/dist/room-recovery-context.js +97 -0
- package/dist/runtime-models.d.ts +17 -0
- package/dist/runtime-models.js +953 -0
- package/dist/runtime-route-options.d.ts +7 -0
- package/dist/runtime-route-options.js +45 -0
- 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-config-map.test.ts +26 -1
- package/src/__tests__/gateway-control.test.ts +136 -0
- package/src/__tests__/policy-resolver.test.ts +20 -0
- package/src/__tests__/provision.test.ts +124 -0
- package/src/__tests__/runtime-discovery.test.ts +68 -9
- package/src/__tests__/runtime-models.test.ts +333 -0
- package/src/agent-discovery.ts +9 -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-config-map.ts +17 -4
- package/src/daemon.ts +38 -9
- package/src/gateway/__tests__/botcord-channel.test.ts +97 -0
- package/src/gateway/__tests__/deepseek-tui-adapter.test.ts +207 -1
- package/src/gateway/__tests__/dispatcher.test.ts +56 -0
- package/src/gateway/channels/botcord.ts +32 -8
- 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 +86 -19
- package/src/gateway/types.ts +31 -59
- package/src/gateway-control.ts +21 -9
- package/src/provision.ts +202 -11
- package/src/room-recovery-context.ts +131 -0
- package/src/runtime-models.ts +972 -0
- package/src/runtime-route-options.ts +52 -0
|
@@ -1,68 +1,10 @@
|
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
/<\/?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,
|
|
19
|
-
"[⚠ stripped: agent-message tag]",
|
|
20
|
-
);
|
|
21
|
-
s = s.replace(
|
|
22
|
-
/<\/?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,
|
|
23
|
-
"[⚠ stripped: human-message tag]",
|
|
24
|
-
);
|
|
25
|
-
s = s.replace(
|
|
26
|
-
/<\/?r[\s]*o[\s]*o[\s]*m[\s]*-[\s]*r[\s]*u[\s]*l[\s]*e[\s\S]*?>/gi,
|
|
27
|
-
"[⚠ stripped: room-rule tag]",
|
|
28
|
-
);
|
|
29
|
-
|
|
30
|
-
return s
|
|
31
|
-
.split(/\r?\n/)
|
|
32
|
-
.map((line) => {
|
|
33
|
-
let l = line;
|
|
34
|
-
l = l.replace(/^\[(BotCord (?:Message|Notification))\]/i, "[⚠ fake: $1]");
|
|
35
|
-
l = l.replace(/^\[Room Rule\]/i, "[⚠ fake: Room Rule]");
|
|
36
|
-
l = l.replace(/^\[房间规则\]/i, "[⚠ fake: 房间规则]");
|
|
37
|
-
l = l.replace(/^\[系统提示\]/i, "[⚠ fake: 系统提示]");
|
|
38
|
-
l = l.replace(/^\[BotCord\s+([^\]\r\n]+)\]/i, (_m, label) => {
|
|
39
|
-
const head = String(label).split(":")[0].trim() || String(label).trim();
|
|
40
|
-
return `[⚠ fake: BotCord ${head}]`;
|
|
41
|
-
});
|
|
42
|
-
l = l.replace(/^\[(System|SYSTEM|Assistant|ASSISTANT|User|USER)\]/, "[⚠ fake: $1]");
|
|
43
|
-
l = l.replace(/<\/?\s*system(?:-reminder)?\s*>/gi, "[⚠ stripped: system tag]");
|
|
44
|
-
l = l.replace(/<\|im_start\|>/gi, "[⚠ stripped: im_start]");
|
|
45
|
-
l = l.replace(/<\|im_end\|>/gi, "[⚠ stripped: im_end]");
|
|
46
|
-
l = l.replace(/\[\/?INST\]/gi, "[⚠ stripped: INST]");
|
|
47
|
-
l = l.replace(/<<\/?SYS>>/gi, "[⚠ stripped: SYS]");
|
|
48
|
-
l = l.replace(/<\s*\/?\|(?:system|user|assistant)\|?\s*>/gi, "[⚠ stripped: role tag]");
|
|
49
|
-
return l;
|
|
50
|
-
})
|
|
51
|
-
.join("\n");
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* Sanitize a sender label so it's safe to embed inside
|
|
56
|
-
* `<agent-message sender="...">`. Must not contain newlines, structural
|
|
57
|
-
* markers, or characters that could break the XML attribute boundary.
|
|
58
|
-
*/
|
|
59
|
-
export function sanitizeSenderName(name: string): string {
|
|
60
|
-
return name
|
|
61
|
-
.replace(/[\n\r]/g, " ")
|
|
62
|
-
.replace(/\[/g, "⟦")
|
|
63
|
-
.replace(/\]/g, "⟧")
|
|
64
|
-
.replace(/"/g, "'")
|
|
65
|
-
.replace(/</g, "<")
|
|
66
|
-
.replace(/>/g, ">")
|
|
67
|
-
.slice(0, 100);
|
|
68
|
-
}
|
|
7
|
+
export {
|
|
8
|
+
sanitizeUntrustedContent,
|
|
9
|
+
sanitizeSenderName,
|
|
10
|
+
} from "@botcord/protocol-core";
|
|
@@ -1,29 +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) return [text];
|
|
15
|
-
if (text.length === 0) return [""];
|
|
16
|
-
if (text.length <= limit) return [text];
|
|
17
|
-
|
|
18
|
-
const out: string[] = [];
|
|
19
|
-
let remaining = text;
|
|
20
|
-
while (remaining.length > limit) {
|
|
21
|
-
let cut = remaining.lastIndexOf("\n", limit);
|
|
22
|
-
if (cut <= 0) cut = limit;
|
|
23
|
-
out.push(remaining.slice(0, cut));
|
|
24
|
-
// Drop the leading newline so the next chunk doesn't start with a blank line.
|
|
25
|
-
remaining = remaining.slice(cut).replace(/^\n/, "");
|
|
26
|
-
}
|
|
27
|
-
if (remaining.length > 0) out.push(remaining);
|
|
28
|
-
return out;
|
|
29
|
-
}
|
|
7
|
+
export { splitText } from "@botcord/protocol-core";
|
|
@@ -23,6 +23,7 @@ import type {
|
|
|
23
23
|
OutboundObserver,
|
|
24
24
|
QueueMode,
|
|
25
25
|
RuntimeAdapter,
|
|
26
|
+
RuntimeRecoveryContextBuilder,
|
|
26
27
|
RuntimeRunResult,
|
|
27
28
|
RuntimeCircuitBreakerSnapshot,
|
|
28
29
|
RuntimeStatusEvent,
|
|
@@ -182,6 +183,31 @@ function extractCloudRunBudget(msg: GatewayInboundMessage): CloudRunBudgetCaps |
|
|
|
182
183
|
return out.maxWallTimeMs !== undefined || out.maxToolCalls !== undefined ? out : undefined;
|
|
183
184
|
}
|
|
184
185
|
|
|
186
|
+
function looksLikeRecoverableSessionFailure(error: string): boolean {
|
|
187
|
+
return /compact|compaction|context|token limit|maximum context|too many tokens|conversation found|session .*not found|resume/i
|
|
188
|
+
.test(error);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function buildRuntimeRecoveryPrompt(args: {
|
|
192
|
+
userTurn: string;
|
|
193
|
+
error: string;
|
|
194
|
+
recoveryContext?: string | null;
|
|
195
|
+
}): string {
|
|
196
|
+
return [
|
|
197
|
+
"[BotCord Runtime Recovery Notice]",
|
|
198
|
+
"The previous Codex runtime session for this room became unrecoverable while resuming or compacting context.",
|
|
199
|
+
`Previous runtime error: ${truncate(args.error, 1000)}`,
|
|
200
|
+
"You are now running in a fresh Codex session.",
|
|
201
|
+
"Use the recent room messages below, current filesystem state, and available BotCord memory/context tools to reconstruct the active task.",
|
|
202
|
+
"Continue the original user request without asking the user to repeat information unless it is missing from those sources.",
|
|
203
|
+
"",
|
|
204
|
+
args.recoveryContext?.trim() || "[Recent Room Messages]\n(unavailable)",
|
|
205
|
+
"",
|
|
206
|
+
"[Current User Turn]",
|
|
207
|
+
args.userTurn,
|
|
208
|
+
].join("\n");
|
|
209
|
+
}
|
|
210
|
+
|
|
185
211
|
/** Factory signature for building a runtime adapter at turn dispatch time. */
|
|
186
212
|
export type RuntimeFactory = (
|
|
187
213
|
runtimeId: string,
|
|
@@ -217,6 +243,11 @@ export interface DispatcherOptions {
|
|
|
217
243
|
* keep following stale memory.
|
|
218
244
|
*/
|
|
219
245
|
buildMemoryContext?: MemoryContextBuilder;
|
|
246
|
+
/**
|
|
247
|
+
* Optional hook that returns recent room context for a fresh-session retry
|
|
248
|
+
* after a runtime resume session becomes unrecoverable.
|
|
249
|
+
*/
|
|
250
|
+
buildRuntimeRecoveryContext?: RuntimeRecoveryContextBuilder;
|
|
220
251
|
/**
|
|
221
252
|
* Optional side-effect hook invoked after ack, before the turn runs.
|
|
222
253
|
* Intended for bookkeeping (e.g. activity tracking). Errors are logged
|
|
@@ -381,6 +412,7 @@ export class Dispatcher {
|
|
|
381
412
|
private readonly runtimeAuthFailureCooldownMs: number;
|
|
382
413
|
private readonly buildSystemContext?: SystemContextBuilder;
|
|
383
414
|
private readonly buildMemoryContext?: MemoryContextBuilder;
|
|
415
|
+
private readonly buildRuntimeRecoveryContext?: RuntimeRecoveryContextBuilder;
|
|
384
416
|
private readonly onInbound?: InboundObserver;
|
|
385
417
|
private readonly onOutbound?: OutboundObserver;
|
|
386
418
|
private readonly onTurnComplete?: DispatcherOptions["onTurnComplete"];
|
|
@@ -415,6 +447,7 @@ export class Dispatcher {
|
|
|
415
447
|
opts.runtimeAuthFailureCooldownMs ?? DEFAULT_RUNTIME_AUTH_FAILURE_COOLDOWN_MS;
|
|
416
448
|
this.buildSystemContext = opts.buildSystemContext;
|
|
417
449
|
this.buildMemoryContext = opts.buildMemoryContext;
|
|
450
|
+
this.buildRuntimeRecoveryContext = opts.buildRuntimeRecoveryContext;
|
|
418
451
|
this.onInbound = opts.onInbound;
|
|
419
452
|
this.onOutbound = opts.onOutbound;
|
|
420
453
|
this.onTurnComplete = opts.onTurnComplete;
|
|
@@ -1604,33 +1637,96 @@ export class Dispatcher {
|
|
|
1604
1637
|
const runtime = this.runtimeFactory(route.runtime, route.extraArgs);
|
|
1605
1638
|
let result: RuntimeRunResult | undefined;
|
|
1606
1639
|
let threw: unknown;
|
|
1640
|
+
let activeSessionId: string | null = sessionId;
|
|
1607
1641
|
const turnStartedAt = Date.now();
|
|
1608
1642
|
try {
|
|
1609
1643
|
try {
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1644
|
+
const runRuntime = (textForRun: string, sessionIdForRun: string | null) =>
|
|
1645
|
+
runtime.run({
|
|
1646
|
+
text: textForRun,
|
|
1647
|
+
sessionId: sessionIdForRun,
|
|
1648
|
+
cwd: route.cwd,
|
|
1649
|
+
accountId: msg.accountId,
|
|
1650
|
+
hubUrl: this.resolveHubUrl?.(msg.accountId),
|
|
1651
|
+
extraArgs: route.extraArgs,
|
|
1652
|
+
signal: controller.signal,
|
|
1653
|
+
trustLevel,
|
|
1654
|
+
systemContext,
|
|
1655
|
+
onBlock,
|
|
1656
|
+
onStatus,
|
|
1657
|
+
context: {
|
|
1658
|
+
turnId,
|
|
1659
|
+
messageId: msg.id,
|
|
1660
|
+
roomId: msg.conversation.id,
|
|
1661
|
+
topicId: msg.conversation.threadId ?? null,
|
|
1662
|
+
channel: msg.channel,
|
|
1663
|
+
conversationKind: msg.conversation.kind,
|
|
1664
|
+
},
|
|
1665
|
+
...(cloudRunBudget ? { budget: cloudRunBudget } : {}),
|
|
1666
|
+
gateway: route.gateway,
|
|
1667
|
+
...(route.hermesProfile ? { hermesProfile: route.hermesProfile } : {}),
|
|
1668
|
+
});
|
|
1669
|
+
|
|
1670
|
+
result = await runRuntime(runtimeText, sessionId);
|
|
1671
|
+
const firstError = result.error ?? "";
|
|
1672
|
+
const firstReply = (result.text || "").trim();
|
|
1673
|
+
const shouldRetryFresh =
|
|
1674
|
+
route.runtime === "codex" &&
|
|
1675
|
+
!!sessionId &&
|
|
1676
|
+
!!firstError &&
|
|
1677
|
+
!firstReply &&
|
|
1678
|
+
!looksLikeRuntimeAuthFailure(firstError) &&
|
|
1679
|
+
looksLikeRecoverableSessionFailure(firstError) &&
|
|
1680
|
+
!controller.signal.aborted &&
|
|
1681
|
+
!slot.timedOut &&
|
|
1682
|
+
!slot.budgetExceeded;
|
|
1683
|
+
|
|
1684
|
+
if (shouldRetryFresh) {
|
|
1685
|
+
try {
|
|
1686
|
+
await this.sessionStore.delete(key);
|
|
1687
|
+
this.log.info("dispatcher: dropped unrecoverable runtime session before fresh retry", {
|
|
1688
|
+
key,
|
|
1689
|
+
prevRuntimeSessionId: sessionId,
|
|
1690
|
+
runtime: route.runtime,
|
|
1691
|
+
error: firstError,
|
|
1692
|
+
});
|
|
1693
|
+
} catch (err) {
|
|
1694
|
+
this.log.warn("dispatcher: session-store.delete failed before fresh retry", {
|
|
1695
|
+
key,
|
|
1696
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1697
|
+
});
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
let recoveryContext: string | null | undefined;
|
|
1701
|
+
if (this.buildRuntimeRecoveryContext) {
|
|
1702
|
+
try {
|
|
1703
|
+
recoveryContext = await this.buildRuntimeRecoveryContext(msg);
|
|
1704
|
+
} catch (err) {
|
|
1705
|
+
this.log.warn("dispatcher: buildRuntimeRecoveryContext threw — retrying without recent room context", {
|
|
1706
|
+
agentId: msg.accountId,
|
|
1707
|
+
roomId: msg.conversation.id,
|
|
1708
|
+
topicId: msg.conversation.threadId ?? null,
|
|
1709
|
+
turnId,
|
|
1710
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1711
|
+
});
|
|
1712
|
+
}
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
activeSessionId = null;
|
|
1716
|
+
runtimeText = buildRuntimeRecoveryPrompt({
|
|
1717
|
+
userTurn: text,
|
|
1718
|
+
error: firstError,
|
|
1719
|
+
recoveryContext,
|
|
1720
|
+
});
|
|
1721
|
+
this.log.info("dispatcher: retrying codex turn in a fresh session with recovery context", {
|
|
1722
|
+
agentId: msg.accountId,
|
|
1625
1723
|
roomId: msg.conversation.id,
|
|
1626
1724
|
topicId: msg.conversation.threadId ?? null,
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
}
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
...(route.hermesProfile ? { hermesProfile: route.hermesProfile } : {}),
|
|
1633
|
-
});
|
|
1725
|
+
turnId,
|
|
1726
|
+
queueKey,
|
|
1727
|
+
});
|
|
1728
|
+
result = await runRuntime(runtimeText, null);
|
|
1729
|
+
}
|
|
1634
1730
|
} catch (err) {
|
|
1635
1731
|
threw = err;
|
|
1636
1732
|
} finally {
|
|
@@ -1814,12 +1910,12 @@ export class Dispatcher {
|
|
|
1814
1910
|
// even when the adapter echoes that id back
|
|
1815
1911
|
// result.newSessionId truthy → upsert the entry
|
|
1816
1912
|
// otherwise → no-op (e.g. codex intentionally never persists)
|
|
1817
|
-
if (
|
|
1913
|
+
if (activeSessionId && effectiveError && !replyText) {
|
|
1818
1914
|
try {
|
|
1819
1915
|
await this.sessionStore.delete(key);
|
|
1820
1916
|
this.log.info("dispatcher: dropped stale runtime session", {
|
|
1821
1917
|
key,
|
|
1822
|
-
prevRuntimeSessionId:
|
|
1918
|
+
prevRuntimeSessionId: activeSessionId,
|
|
1823
1919
|
nextRuntimeSessionId: result.newSessionId || null,
|
|
1824
1920
|
error: effectiveError,
|
|
1825
1921
|
});
|
|
@@ -1844,7 +1940,7 @@ export class Dispatcher {
|
|
|
1844
1940
|
updatedAt: Date.now(),
|
|
1845
1941
|
};
|
|
1846
1942
|
try {
|
|
1847
|
-
const prevRuntimeSessionId =
|
|
1943
|
+
const prevRuntimeSessionId = activeSessionId;
|
|
1848
1944
|
await this.sessionStore.set(session);
|
|
1849
1945
|
this.log.debug("dispatcher: persisted runtime session", {
|
|
1850
1946
|
key,
|
|
@@ -1857,12 +1953,12 @@ export class Dispatcher {
|
|
|
1857
1953
|
error: err instanceof Error ? err.message : String(err),
|
|
1858
1954
|
});
|
|
1859
1955
|
}
|
|
1860
|
-
} else if (
|
|
1956
|
+
} else if (activeSessionId && effectiveError) {
|
|
1861
1957
|
try {
|
|
1862
1958
|
await this.sessionStore.delete(key);
|
|
1863
1959
|
this.log.info("dispatcher: dropped stale runtime session", {
|
|
1864
1960
|
key,
|
|
1865
|
-
prevRuntimeSessionId:
|
|
1961
|
+
prevRuntimeSessionId: activeSessionId,
|
|
1866
1962
|
error: effectiveError,
|
|
1867
1963
|
});
|
|
1868
1964
|
} catch (err) {
|
package/src/gateway/gateway.ts
CHANGED
|
@@ -23,6 +23,7 @@ import type {
|
|
|
23
23
|
InboundObserver,
|
|
24
24
|
MemoryContextBuilder,
|
|
25
25
|
OutboundObserver,
|
|
26
|
+
RuntimeRecoveryContextBuilder,
|
|
26
27
|
SystemContextBuilder,
|
|
27
28
|
UserTurnBuilder,
|
|
28
29
|
} from "./types.js";
|
|
@@ -49,6 +50,11 @@ export interface GatewayBootOptions {
|
|
|
49
50
|
* resumed runtime sessions get an explicit prompt when memory changes.
|
|
50
51
|
*/
|
|
51
52
|
buildMemoryContext?: MemoryContextBuilder;
|
|
53
|
+
/**
|
|
54
|
+
* Recent room context provider used by dispatcher when it must discard a
|
|
55
|
+
* broken runtime session and retry the same turn in a fresh session.
|
|
56
|
+
*/
|
|
57
|
+
buildRuntimeRecoveryContext?: RuntimeRecoveryContextBuilder;
|
|
52
58
|
/**
|
|
53
59
|
* Observer called after the dispatcher acks each inbound message. Useful
|
|
54
60
|
* for activity tracking or metrics. Errors are logged and swallowed.
|
|
@@ -178,6 +184,7 @@ export class Gateway {
|
|
|
178
184
|
turnTimeoutMs: opts.turnTimeoutMs,
|
|
179
185
|
buildSystemContext: opts.buildSystemContext,
|
|
180
186
|
buildMemoryContext: opts.buildMemoryContext,
|
|
187
|
+
buildRuntimeRecoveryContext: opts.buildRuntimeRecoveryContext,
|
|
181
188
|
onInbound: opts.onInbound,
|
|
182
189
|
composeUserTurn: opts.composeUserTurn,
|
|
183
190
|
onOutbound: opts.onOutbound,
|
|
@@ -295,6 +302,28 @@ export class Gateway {
|
|
|
295
302
|
await this.dispatcher.handle({ message });
|
|
296
303
|
}
|
|
297
304
|
|
|
305
|
+
/**
|
|
306
|
+
* Inject an inbound message while routing replies through a caller-owned
|
|
307
|
+
* channel adapter. Cloud gateway runtime sessions use this to execute a
|
|
308
|
+
* provider message without loading provider credentials inside the sandbox:
|
|
309
|
+
* the temporary adapter captures the runtime's final reply and the always-on
|
|
310
|
+
* ingress service performs the provider send.
|
|
311
|
+
*/
|
|
312
|
+
async injectInboundThrough(
|
|
313
|
+
message: GatewayInboundMessage,
|
|
314
|
+
channel: ChannelAdapter,
|
|
315
|
+
ack?: { accept: () => Promise<void> },
|
|
316
|
+
): Promise<void> {
|
|
317
|
+
const previous = this.channelMap.get(channel.id);
|
|
318
|
+
this.channelMap.set(channel.id, channel);
|
|
319
|
+
try {
|
|
320
|
+
await this.dispatcher.handle({ message, ...(ack ? { ack } : {}) });
|
|
321
|
+
} finally {
|
|
322
|
+
if (previous) this.channelMap.set(channel.id, previous);
|
|
323
|
+
else this.channelMap.delete(channel.id);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
298
327
|
/**
|
|
299
328
|
* Send a daemon-control initiated outbound message through a registered
|
|
300
329
|
* channel. Used by proactive third-party gateway sends where the runtime
|
|
@@ -65,20 +65,31 @@ const DEFAULT_TTL_MS = 5 * 60 * 1000;
|
|
|
65
65
|
const FETCH_FAILED = Symbol("fetch_failed");
|
|
66
66
|
|
|
67
67
|
/**
|
|
68
|
-
* Force
|
|
68
|
+
* Force direct conversations to `mode: "always"` per design §4.2 — UI never
|
|
69
69
|
* lets the user mute a DM, but a stale cache from before a UX bug is cheap
|
|
70
|
-
* to defend against here.
|
|
70
|
+
* to defend against here. Third-party 1:1 gateway chats have the same
|
|
71
|
+
* expectation: they do not carry BotCord mention metadata, so applying a
|
|
72
|
+
* global mention-only policy would silently drop ordinary direct messages.
|
|
71
73
|
*/
|
|
72
|
-
function
|
|
74
|
+
function maybeForceDirectConversation(
|
|
73
75
|
roomId: string | null,
|
|
74
76
|
policy: DaemonAttentionPolicy,
|
|
75
77
|
): DaemonAttentionPolicy {
|
|
76
|
-
if (roomId && roomId
|
|
78
|
+
if (roomId && isDirectConversation(roomId) && policy.mode !== "always") {
|
|
77
79
|
return { ...policy, mode: "always" };
|
|
78
80
|
}
|
|
79
81
|
return policy;
|
|
80
82
|
}
|
|
81
83
|
|
|
84
|
+
function isDirectConversation(roomId: string): boolean {
|
|
85
|
+
return (
|
|
86
|
+
roomId.startsWith("rm_dm_") ||
|
|
87
|
+
roomId.startsWith("telegram:user:") ||
|
|
88
|
+
roomId.startsWith("wechat:user:") ||
|
|
89
|
+
roomId.startsWith("feishu:user:")
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
82
93
|
function defaultPolicy(): DaemonAttentionPolicy {
|
|
83
94
|
return { mode: "always", keywords: [] };
|
|
84
95
|
}
|
|
@@ -115,10 +126,10 @@ export class PolicyResolver implements PolicyResolverLike {
|
|
|
115
126
|
if (fetched === FETCH_FAILED) return defaultPolicy();
|
|
116
127
|
const policy = fetched ?? defaultPolicy();
|
|
117
128
|
this.cache.set(cacheKey(agentId, roomId), {
|
|
118
|
-
policy:
|
|
129
|
+
policy: maybeForceDirectConversation(roomId, policy),
|
|
119
130
|
expiresAt: now + this.ttlMs,
|
|
120
131
|
});
|
|
121
|
-
return
|
|
132
|
+
return maybeForceDirectConversation(roomId, policy);
|
|
122
133
|
}
|
|
123
134
|
|
|
124
135
|
// 3. No room override known — inherit from the cached agent-wide global.
|
|
@@ -128,7 +139,7 @@ export class PolicyResolver implements PolicyResolverLike {
|
|
|
128
139
|
const globalKey = cacheKey(agentId, null);
|
|
129
140
|
const globalHit = this.cache.get(globalKey);
|
|
130
141
|
if (globalHit && globalHit.expiresAt > now) {
|
|
131
|
-
return
|
|
142
|
+
return maybeForceDirectConversation(roomId, globalHit.policy);
|
|
132
143
|
}
|
|
133
144
|
|
|
134
145
|
// 4. Cold start for global.
|
|
@@ -136,7 +147,7 @@ export class PolicyResolver implements PolicyResolverLike {
|
|
|
136
147
|
if (fetched === FETCH_FAILED) return defaultPolicy();
|
|
137
148
|
const policy = fetched ?? defaultPolicy();
|
|
138
149
|
this.cache.set(globalKey, { policy, expiresAt: now + this.ttlMs });
|
|
139
|
-
return
|
|
150
|
+
return maybeForceDirectConversation(roomId, policy);
|
|
140
151
|
}
|
|
141
152
|
|
|
142
153
|
private async safeFetch(
|
|
@@ -168,7 +179,7 @@ export class PolicyResolver implements PolicyResolverLike {
|
|
|
168
179
|
put(agentId: string, roomId: string | null, policy: DaemonAttentionPolicy): void {
|
|
169
180
|
const key = cacheKey(agentId, roomId);
|
|
170
181
|
this.cache.set(key, {
|
|
171
|
-
policy:
|
|
182
|
+
policy: maybeForceDirectConversation(roomId, policy),
|
|
172
183
|
expiresAt: Date.now() + this.ttlMs,
|
|
173
184
|
});
|
|
174
185
|
}
|
|
@@ -260,6 +260,9 @@ export class DeepseekTuiAdapter implements RuntimeAdapter {
|
|
|
260
260
|
auto_approve: opts.trustLevel !== "public",
|
|
261
261
|
archived: false,
|
|
262
262
|
};
|
|
263
|
+
const selection = parseDeepseekRuntimeSelection(opts.extraArgs);
|
|
264
|
+
if (selection.model) body.model = selection.model;
|
|
265
|
+
if (selection.reasoningEffort) body.reasoning_effort = selection.reasoningEffort;
|
|
263
266
|
if (opts.systemContext) body.system_prompt = opts.systemContext;
|
|
264
267
|
const res = await this.requestJson<any>(`${baseUrl}/v1/threads`, {
|
|
265
268
|
method: "POST",
|
|
@@ -306,18 +309,22 @@ export class DeepseekTuiAdapter implements RuntimeAdapter {
|
|
|
306
309
|
});
|
|
307
310
|
let turnId = "";
|
|
308
311
|
try {
|
|
312
|
+
const selection = parseDeepseekRuntimeSelection(opts.extraArgs);
|
|
313
|
+
const body: Record<string, unknown> = {
|
|
314
|
+
prompt: opts.text,
|
|
315
|
+
mode: "agent",
|
|
316
|
+
allow_shell: opts.trustLevel !== "public",
|
|
317
|
+
trust_mode: opts.trustLevel !== "public",
|
|
318
|
+
auto_approve: opts.trustLevel !== "public",
|
|
319
|
+
};
|
|
320
|
+
if (selection.model) body.model = selection.model;
|
|
321
|
+
if (selection.reasoningEffort) body.reasoning_effort = selection.reasoningEffort;
|
|
309
322
|
const started = await this.requestJson<any>(
|
|
310
323
|
`${baseUrl}/v1/threads/${encodeURIComponent(threadId)}/turns`,
|
|
311
324
|
{
|
|
312
325
|
method: "POST",
|
|
313
326
|
headers,
|
|
314
|
-
body: JSON.stringify(
|
|
315
|
-
prompt: opts.text,
|
|
316
|
-
mode: "agent",
|
|
317
|
-
allow_shell: opts.trustLevel !== "public",
|
|
318
|
-
trust_mode: opts.trustLevel !== "public",
|
|
319
|
-
auto_approve: opts.trustLevel !== "public",
|
|
320
|
-
}),
|
|
327
|
+
body: JSON.stringify(body),
|
|
321
328
|
signal,
|
|
322
329
|
},
|
|
323
330
|
);
|
|
@@ -376,13 +383,17 @@ export class DeepseekTuiAdapter implements RuntimeAdapter {
|
|
|
376
383
|
if (extractedError) errorText = extractedError;
|
|
377
384
|
if (eventName === "message.delta") {
|
|
378
385
|
append(stringField(payload, "content") ?? "");
|
|
379
|
-
} else if (eventName === "item.delta" && payload
|
|
380
|
-
append(
|
|
386
|
+
} else if (eventName === "item.delta" && isAgentMessageDelta(payload)) {
|
|
387
|
+
append(extractDeepseekDelta(payload));
|
|
381
388
|
}
|
|
382
389
|
if (eventName === "turn.started" || embeddedDeepseekEvent(payload) === "turn.started") {
|
|
383
390
|
opts.onStatus?.({ kind: "thinking", phase: "started", label: "Thinking" });
|
|
384
|
-
} else if (eventName === "tool.started" || isToolStarted(payload)) {
|
|
385
|
-
const label =
|
|
391
|
+
} else if (eventName === "tool.started" || isToolStarted(eventName, payload)) {
|
|
392
|
+
const label =
|
|
393
|
+
stringField(payload, "name") ??
|
|
394
|
+
stringField(payload?.tool, "name") ??
|
|
395
|
+
stringField(payload?.payload?.tool, "name") ??
|
|
396
|
+
"tool";
|
|
386
397
|
opts.onStatus?.({ kind: "thinking", phase: "updated", label });
|
|
387
398
|
} else if (isDeepseekTerminalEvent(eventName, payload)) {
|
|
388
399
|
opts.onStatus?.({ kind: "thinking", phase: "stopped" });
|
|
@@ -442,15 +453,18 @@ function normalizeDeepseekEvent(eventName: string, payload: any, seq: number): S
|
|
|
442
453
|
if (eventName === "message.delta") {
|
|
443
454
|
return { raw: { event: eventName, payload }, kind: "assistant_text", seq };
|
|
444
455
|
}
|
|
445
|
-
if (eventName === "tool.started" || isToolStarted(payload)) {
|
|
456
|
+
if (eventName === "tool.started" || isToolStarted(eventName, payload)) {
|
|
446
457
|
return { raw: { event: eventName, payload }, kind: "tool_use", seq };
|
|
447
458
|
}
|
|
448
|
-
if (eventName === "tool.completed" || isToolCompleted(payload)) {
|
|
459
|
+
if (eventName === "tool.completed" || isToolCompleted(eventName, payload)) {
|
|
449
460
|
return { raw: { event: eventName, payload }, kind: "tool_result", seq };
|
|
450
461
|
}
|
|
451
|
-
if (eventName === "item.delta" && payload
|
|
462
|
+
if (eventName === "item.delta" && isAgentMessageDelta(payload)) {
|
|
452
463
|
return { raw: { event: eventName, payload }, kind: "assistant_text", seq };
|
|
453
464
|
}
|
|
465
|
+
if (eventName === "item.completed" && isAgentReasoningItem(payload)) {
|
|
466
|
+
return { raw: { event: eventName, payload }, kind: "thinking", seq };
|
|
467
|
+
}
|
|
454
468
|
if (eventName === "turn.started" || eventName === "status" || embeddedDeepseekEvent(payload) === "turn.started") {
|
|
455
469
|
return { raw: { event: eventName, payload }, kind: "system", seq };
|
|
456
470
|
}
|
|
@@ -478,18 +492,36 @@ function isDeepseekTerminalEvent(eventName: string, payload: any): boolean {
|
|
|
478
492
|
);
|
|
479
493
|
}
|
|
480
494
|
|
|
481
|
-
function isToolStarted(payload: any): boolean {
|
|
482
|
-
return
|
|
495
|
+
function isToolStarted(eventName: string, payload: any): boolean {
|
|
496
|
+
return (
|
|
497
|
+
(eventName === "item.started" && (!!payload?.tool || payload?.item?.kind === "tool_call")) ||
|
|
498
|
+
(payload?.event === "item.started" && !!payload?.payload?.tool)
|
|
499
|
+
);
|
|
483
500
|
}
|
|
484
501
|
|
|
485
|
-
function isToolCompleted(payload: any): boolean {
|
|
486
|
-
const kind = payload?.payload?.item?.kind;
|
|
502
|
+
function isToolCompleted(eventName: string, payload: any): boolean {
|
|
503
|
+
const kind = payload?.payload?.item?.kind ?? payload?.item?.kind;
|
|
487
504
|
return (
|
|
488
|
-
(
|
|
505
|
+
(eventName === "item.completed" ||
|
|
506
|
+
eventName === "item.failed" ||
|
|
507
|
+
payload?.event === "item.completed" ||
|
|
508
|
+
payload?.event === "item.failed") &&
|
|
489
509
|
(kind === "tool_call" || kind === "file_change" || kind === "command_execution")
|
|
490
510
|
);
|
|
491
511
|
}
|
|
492
512
|
|
|
513
|
+
function isAgentMessageDelta(payload: any): boolean {
|
|
514
|
+
return payload?.kind === "agent_message" || payload?.payload?.kind === "agent_message";
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function isAgentReasoningItem(payload: any): boolean {
|
|
518
|
+
return payload?.item?.kind === "agent_reasoning" || payload?.payload?.item?.kind === "agent_reasoning";
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function extractDeepseekDelta(payload: any): string {
|
|
522
|
+
return stringField(payload, "delta") ?? stringField(payload?.payload, "delta") ?? "";
|
|
523
|
+
}
|
|
524
|
+
|
|
493
525
|
function extractDeepseekError(eventName: string, payload: any): string | undefined {
|
|
494
526
|
if (eventName === "error") {
|
|
495
527
|
return (
|
|
@@ -535,6 +567,41 @@ function authHeaders(token: string): HeadersInit {
|
|
|
535
567
|
return token ? { authorization: `Bearer ${token}` } : {};
|
|
536
568
|
}
|
|
537
569
|
|
|
570
|
+
function parseDeepseekRuntimeSelection(
|
|
571
|
+
extraArgs: string[] | undefined,
|
|
572
|
+
): { model?: string; reasoningEffort?: string } {
|
|
573
|
+
const out: { model?: string; reasoningEffort?: string } = {};
|
|
574
|
+
if (!extraArgs?.length) return out;
|
|
575
|
+
for (let i = 0; i < extraArgs.length; i += 1) {
|
|
576
|
+
const arg = extraArgs[i]!;
|
|
577
|
+
if (arg === "--model") {
|
|
578
|
+
const value = nextArgValue(extraArgs, i);
|
|
579
|
+
if (value !== undefined) {
|
|
580
|
+
out.model = value;
|
|
581
|
+
i += 1;
|
|
582
|
+
}
|
|
583
|
+
} else if (arg.startsWith("--model=")) {
|
|
584
|
+
out.model = arg.slice("--model=".length);
|
|
585
|
+
} else if (arg === "--reasoning-effort") {
|
|
586
|
+
const value = nextArgValue(extraArgs, i);
|
|
587
|
+
if (value !== undefined) {
|
|
588
|
+
out.reasoningEffort = value;
|
|
589
|
+
i += 1;
|
|
590
|
+
}
|
|
591
|
+
} else if (arg.startsWith("--reasoning-effort=")) {
|
|
592
|
+
out.reasoningEffort = arg.slice("--reasoning-effort=".length);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
return out;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function nextArgValue(args: string[], index: number): string | undefined {
|
|
599
|
+
const next = args[index + 1];
|
|
600
|
+
if (typeof next !== "string") return undefined;
|
|
601
|
+
if (!next.startsWith("-")) return next;
|
|
602
|
+
return /^-\d/.test(next) ? next : undefined;
|
|
603
|
+
}
|
|
604
|
+
|
|
538
605
|
function poolKey(opts: RuntimeRunOptions): string {
|
|
539
606
|
return opts.accountId || "default";
|
|
540
607
|
}
|