@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
|
@@ -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
|
}
|
|
@@ -143,11 +143,14 @@ export class DeepseekTuiAdapter implements RuntimeAdapter {
|
|
|
143
143
|
signal: turnAbort.signal,
|
|
144
144
|
});
|
|
145
145
|
const text = runResult.text;
|
|
146
|
+
const error =
|
|
147
|
+
runResult.error ??
|
|
148
|
+
(text === "" ? emptyCompletionError(handle.stderrTail) : undefined);
|
|
146
149
|
|
|
147
150
|
return {
|
|
148
151
|
text,
|
|
149
152
|
newSessionId: threadId,
|
|
150
|
-
...(
|
|
153
|
+
...(error ? { error } : {}),
|
|
151
154
|
};
|
|
152
155
|
} catch (err) {
|
|
153
156
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -383,13 +386,18 @@ export class DeepseekTuiAdapter implements RuntimeAdapter {
|
|
|
383
386
|
if (extractedError) errorText = extractedError;
|
|
384
387
|
if (eventName === "message.delta") {
|
|
385
388
|
append(stringField(payload, "content") ?? "");
|
|
386
|
-
} else if (eventName === "item.delta" && payload
|
|
387
|
-
append(
|
|
389
|
+
} else if (eventName === "item.delta" && isAgentMessageDelta(payload)) {
|
|
390
|
+
append(extractDeepseekDelta(payload));
|
|
388
391
|
}
|
|
389
392
|
if (eventName === "turn.started" || embeddedDeepseekEvent(payload) === "turn.started") {
|
|
390
393
|
opts.onStatus?.({ kind: "thinking", phase: "started", label: "Thinking" });
|
|
391
|
-
} else if (eventName === "tool.started" || isToolStarted(payload)) {
|
|
392
|
-
const label =
|
|
394
|
+
} else if (eventName === "tool.started" || isToolStarted(eventName, payload)) {
|
|
395
|
+
const label =
|
|
396
|
+
stringField(payload, "name") ??
|
|
397
|
+
stringField(payload?.tool, "name") ??
|
|
398
|
+
stringField(payload?.payload?.tool, "name") ??
|
|
399
|
+
inferDeepseekToolName(payload?.item ?? payload?.payload?.item) ??
|
|
400
|
+
"tool";
|
|
393
401
|
opts.onStatus?.({ kind: "thinking", phase: "updated", label });
|
|
394
402
|
} else if (isDeepseekTerminalEvent(eventName, payload)) {
|
|
395
403
|
opts.onStatus?.({ kind: "thinking", phase: "stopped" });
|
|
@@ -449,15 +457,18 @@ function normalizeDeepseekEvent(eventName: string, payload: any, seq: number): S
|
|
|
449
457
|
if (eventName === "message.delta") {
|
|
450
458
|
return { raw: { event: eventName, payload }, kind: "assistant_text", seq };
|
|
451
459
|
}
|
|
452
|
-
if (eventName === "tool.started" || isToolStarted(payload)) {
|
|
460
|
+
if (eventName === "tool.started" || isToolStarted(eventName, payload)) {
|
|
453
461
|
return { raw: { event: eventName, payload }, kind: "tool_use", seq };
|
|
454
462
|
}
|
|
455
|
-
if (eventName === "tool.completed" || isToolCompleted(payload)) {
|
|
463
|
+
if (eventName === "tool.completed" || isToolCompleted(eventName, payload)) {
|
|
456
464
|
return { raw: { event: eventName, payload }, kind: "tool_result", seq };
|
|
457
465
|
}
|
|
458
|
-
if (eventName === "item.delta" && payload
|
|
466
|
+
if (eventName === "item.delta" && isAgentMessageDelta(payload)) {
|
|
459
467
|
return { raw: { event: eventName, payload }, kind: "assistant_text", seq };
|
|
460
468
|
}
|
|
469
|
+
if (eventName === "item.completed" && isAgentReasoningItem(payload)) {
|
|
470
|
+
return { raw: { event: eventName, payload }, kind: "thinking", seq };
|
|
471
|
+
}
|
|
461
472
|
if (eventName === "turn.started" || eventName === "status" || embeddedDeepseekEvent(payload) === "turn.started") {
|
|
462
473
|
return { raw: { event: eventName, payload }, kind: "system", seq };
|
|
463
474
|
}
|
|
@@ -485,18 +496,57 @@ function isDeepseekTerminalEvent(eventName: string, payload: any): boolean {
|
|
|
485
496
|
);
|
|
486
497
|
}
|
|
487
498
|
|
|
488
|
-
function isToolStarted(payload: any): boolean {
|
|
489
|
-
return
|
|
499
|
+
function isToolStarted(eventName: string, payload: any): boolean {
|
|
500
|
+
return (
|
|
501
|
+
(eventName === "item.started" &&
|
|
502
|
+
(!!payload?.tool || payload?.item?.kind === "tool_call" || payload?.payload?.item?.kind === "tool_call")) ||
|
|
503
|
+
(payload?.event === "item.started" && !!payload?.payload?.tool)
|
|
504
|
+
);
|
|
490
505
|
}
|
|
491
506
|
|
|
492
|
-
function isToolCompleted(payload: any): boolean {
|
|
493
|
-
const kind = payload?.payload?.item?.kind;
|
|
507
|
+
function isToolCompleted(eventName: string, payload: any): boolean {
|
|
508
|
+
const kind = payload?.payload?.item?.kind ?? payload?.item?.kind;
|
|
494
509
|
return (
|
|
495
|
-
(
|
|
510
|
+
(eventName === "item.completed" ||
|
|
511
|
+
eventName === "item.failed" ||
|
|
512
|
+
payload?.event === "item.completed" ||
|
|
513
|
+
payload?.event === "item.failed") &&
|
|
496
514
|
(kind === "tool_call" || kind === "file_change" || kind === "command_execution")
|
|
497
515
|
);
|
|
498
516
|
}
|
|
499
517
|
|
|
518
|
+
function isAgentMessageDelta(payload: any): boolean {
|
|
519
|
+
return payload?.kind === "agent_message" || payload?.payload?.kind === "agent_message";
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function isAgentReasoningItem(payload: any): boolean {
|
|
523
|
+
return payload?.item?.kind === "agent_reasoning" || payload?.payload?.item?.kind === "agent_reasoning";
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function extractDeepseekDelta(payload: any): string {
|
|
527
|
+
return stringField(payload, "delta") ?? stringField(payload?.payload, "delta") ?? "";
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function inferDeepseekToolName(item: any): string | undefined {
|
|
531
|
+
const candidates = [stringField(item, "summary"), stringField(item, "detail")];
|
|
532
|
+
for (const candidate of candidates) {
|
|
533
|
+
if (!candidate) continue;
|
|
534
|
+
const match = candidate.match(/^([A-Za-z0-9_.:-]+)\s*(?:started|completed|failed|returned|:)/);
|
|
535
|
+
if (match?.[1] && match[1] !== "tool_call") return match[1];
|
|
536
|
+
}
|
|
537
|
+
return undefined;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function emptyCompletionError(stderrTail: string): string {
|
|
541
|
+
const tail = stderrTail.trim();
|
|
542
|
+
if (!tail) {
|
|
543
|
+
return "deepseek runtime completed with no assistant_message (check DEEPSEEK_API_KEY / model availability)";
|
|
544
|
+
}
|
|
545
|
+
const lines = tail.split(/\r?\n/).filter((line) => line.trim().length > 0);
|
|
546
|
+
const lastLines = lines.slice(-5).join("\n").slice(-500);
|
|
547
|
+
return `deepseek runtime completed with no assistant_message; stderr tail: ${lastLines}`;
|
|
548
|
+
}
|
|
549
|
+
|
|
500
550
|
function extractDeepseekError(eventName: string, payload: any): string | undefined {
|
|
501
551
|
if (eventName === "error") {
|
|
502
552
|
return (
|
package/src/gateway/types.ts
CHANGED
|
@@ -1,4 +1,20 @@
|
|
|
1
1
|
import type { GatewayLogger } from "./log.js";
|
|
2
|
+
import type {
|
|
3
|
+
GatewayInboundEnvelope as CanonicalGatewayInboundEnvelope,
|
|
4
|
+
GatewayInboundMessage as CanonicalGatewayInboundMessage,
|
|
5
|
+
GatewayOutboundAttachment as CanonicalGatewayOutboundAttachment,
|
|
6
|
+
GatewayOutboundMessage as CanonicalGatewayOutboundMessage,
|
|
7
|
+
RuntimeGatewayProvider,
|
|
8
|
+
} from "@botcord/protocol-core";
|
|
9
|
+
|
|
10
|
+
// Canonical gateway message shapes live in `@botcord/protocol-core` so the
|
|
11
|
+
// `gateway-ingress` provider adapters can import the same types without
|
|
12
|
+
// pulling the entire daemon. Daemon re-exports the canonical shapes here so
|
|
13
|
+
// every existing import (`from "./gateway/index.js"`) keeps working.
|
|
14
|
+
export type GatewayInboundMessage = CanonicalGatewayInboundMessage;
|
|
15
|
+
export type GatewayInboundEnvelope = CanonicalGatewayInboundEnvelope;
|
|
16
|
+
export type GatewayOutboundAttachment = CanonicalGatewayOutboundAttachment;
|
|
17
|
+
export type GatewayOutboundMessage = CanonicalGatewayOutboundMessage;
|
|
2
18
|
|
|
3
19
|
// ---------------------------------------------------------------------------
|
|
4
20
|
// Routing (§9)
|
|
@@ -89,42 +105,9 @@ export interface GatewayConfig {
|
|
|
89
105
|
// Inbound / outbound message shape (§7.3, §7.4, §7.5)
|
|
90
106
|
// ---------------------------------------------------------------------------
|
|
91
107
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
/** Channel adapter id (`ChannelAdapter.id`), not channel type. */
|
|
96
|
-
channel: string;
|
|
97
|
-
accountId: string;
|
|
98
|
-
conversation: {
|
|
99
|
-
id: string;
|
|
100
|
-
kind: "direct" | "group";
|
|
101
|
-
title?: string;
|
|
102
|
-
threadId?: string | null;
|
|
103
|
-
};
|
|
104
|
-
sender: {
|
|
105
|
-
id: string;
|
|
106
|
-
name?: string;
|
|
107
|
-
kind: "user" | "agent" | "system";
|
|
108
|
-
};
|
|
109
|
-
text?: string;
|
|
110
|
-
raw: unknown;
|
|
111
|
-
replyTo?: string | null;
|
|
112
|
-
mentioned?: boolean;
|
|
113
|
-
receivedAt: number;
|
|
114
|
-
trace?: {
|
|
115
|
-
id: string;
|
|
116
|
-
streamable?: boolean;
|
|
117
|
-
};
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
/** Inbound envelope wrapping a normalized message with optional upstream ack callbacks. */
|
|
121
|
-
export interface GatewayInboundEnvelope {
|
|
122
|
-
message: GatewayInboundMessage;
|
|
123
|
-
ack?: {
|
|
124
|
-
accept(): Promise<void>;
|
|
125
|
-
reject?(reason: string): Promise<void>;
|
|
126
|
-
};
|
|
127
|
-
}
|
|
108
|
+
// `GatewayInboundMessage` and `GatewayInboundEnvelope` are re-exported from
|
|
109
|
+
// `@botcord/protocol-core` at the top of this file. The wire-level subset is
|
|
110
|
+
// `RuntimeGatewayInboundPayload` in protocol-core/runtime-frame.ts.
|
|
128
111
|
|
|
129
112
|
/**
|
|
130
113
|
* Channel-agnostic hook that produces a system-context string for a turn.
|
|
@@ -170,6 +153,15 @@ export type MemoryContextBuilder = (
|
|
|
170
153
|
message: GatewayInboundMessage,
|
|
171
154
|
) => Promise<MemoryContextSnapshot | null | undefined> | MemoryContextSnapshot | null | undefined;
|
|
172
155
|
|
|
156
|
+
/**
|
|
157
|
+
* Optional hook used after a runtime session is discarded and retried fresh.
|
|
158
|
+
* The daemon implementation can pull recent room messages from Hub; gateway
|
|
159
|
+
* core treats the returned string as opaque recovery context.
|
|
160
|
+
*/
|
|
161
|
+
export type RuntimeRecoveryContextBuilder = (
|
|
162
|
+
message: GatewayInboundMessage,
|
|
163
|
+
) => Promise<string | null | undefined> | string | null | undefined;
|
|
164
|
+
|
|
173
165
|
/**
|
|
174
166
|
* Optional hook fired after the dispatcher dispatches a reply to a channel.
|
|
175
167
|
* Intended for outbound bookkeeping (loop-risk tracking, metrics). Errors
|
|
@@ -179,28 +171,8 @@ export type OutboundObserver = (
|
|
|
179
171
|
message: GatewayOutboundMessage,
|
|
180
172
|
) => Promise<void> | void;
|
|
181
173
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
/** Local daemon-readable file path. */
|
|
185
|
-
filePath?: string;
|
|
186
|
-
/** In-memory bytes, primarily for tests and in-process tool callers. */
|
|
187
|
-
data?: Uint8Array;
|
|
188
|
-
filename?: string;
|
|
189
|
-
contentType?: string;
|
|
190
|
-
kind?: "image" | "file" | "video";
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
export interface GatewayOutboundMessage {
|
|
194
|
-
channel: string;
|
|
195
|
-
accountId: string;
|
|
196
|
-
conversationId: string;
|
|
197
|
-
threadId?: string | null;
|
|
198
|
-
type?: "message" | "error";
|
|
199
|
-
text: string;
|
|
200
|
-
attachments?: GatewayOutboundAttachment[];
|
|
201
|
-
replyTo?: string | null;
|
|
202
|
-
traceId?: string | null;
|
|
203
|
-
}
|
|
174
|
+
// `GatewayOutboundAttachment` and `GatewayOutboundMessage` are re-exported from
|
|
175
|
+
// `@botcord/protocol-core` at the top of this file.
|
|
204
176
|
|
|
205
177
|
// ---------------------------------------------------------------------------
|
|
206
178
|
// Status (§14)
|
|
@@ -218,7 +190,7 @@ export interface ChannelStatusSnapshot {
|
|
|
218
190
|
lastStopAt?: number;
|
|
219
191
|
lastError?: string | null;
|
|
220
192
|
/** Third-party provider id when this channel is not the built-in BotCord. */
|
|
221
|
-
provider?:
|
|
193
|
+
provider?: RuntimeGatewayProvider;
|
|
222
194
|
/** Last time the adapter polled the upstream provider (ms epoch). */
|
|
223
195
|
lastPollAt?: number;
|
|
224
196
|
/** Last time the adapter accepted an inbound message (ms epoch). */
|