@botcord/daemon 0.2.78 → 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/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.js +21 -6
- 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 +31 -12
- package/dist/gateway/types.d.ts +12 -57
- package/dist/gateway-control.js +18 -9
- 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/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__/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/attention-policy-fetcher.ts +87 -0
- package/src/cloud-daemon.ts +8 -0
- package/src/cloud-gateway-runtime.ts +171 -0
- package/src/daemon.ts +23 -6
- package/src/gateway/__tests__/botcord-channel.test.ts +97 -0
- package/src/gateway/__tests__/deepseek-tui-adapter.test.ts +177 -0
- 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 +37 -12
- package/src/gateway/types.ts +31 -59
- package/src/gateway-control.ts +21 -9
- package/src/provision.ts +133 -7
- package/src/room-recovery-context.ts +131 -0
|
@@ -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
|
}
|
|
@@ -383,13 +383,17 @@ export class DeepseekTuiAdapter implements RuntimeAdapter {
|
|
|
383
383
|
if (extractedError) errorText = extractedError;
|
|
384
384
|
if (eventName === "message.delta") {
|
|
385
385
|
append(stringField(payload, "content") ?? "");
|
|
386
|
-
} else if (eventName === "item.delta" && payload
|
|
387
|
-
append(
|
|
386
|
+
} else if (eventName === "item.delta" && isAgentMessageDelta(payload)) {
|
|
387
|
+
append(extractDeepseekDelta(payload));
|
|
388
388
|
}
|
|
389
389
|
if (eventName === "turn.started" || embeddedDeepseekEvent(payload) === "turn.started") {
|
|
390
390
|
opts.onStatus?.({ kind: "thinking", phase: "started", label: "Thinking" });
|
|
391
|
-
} else if (eventName === "tool.started" || isToolStarted(payload)) {
|
|
392
|
-
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";
|
|
393
397
|
opts.onStatus?.({ kind: "thinking", phase: "updated", label });
|
|
394
398
|
} else if (isDeepseekTerminalEvent(eventName, payload)) {
|
|
395
399
|
opts.onStatus?.({ kind: "thinking", phase: "stopped" });
|
|
@@ -449,15 +453,18 @@ function normalizeDeepseekEvent(eventName: string, payload: any, seq: number): S
|
|
|
449
453
|
if (eventName === "message.delta") {
|
|
450
454
|
return { raw: { event: eventName, payload }, kind: "assistant_text", seq };
|
|
451
455
|
}
|
|
452
|
-
if (eventName === "tool.started" || isToolStarted(payload)) {
|
|
456
|
+
if (eventName === "tool.started" || isToolStarted(eventName, payload)) {
|
|
453
457
|
return { raw: { event: eventName, payload }, kind: "tool_use", seq };
|
|
454
458
|
}
|
|
455
|
-
if (eventName === "tool.completed" || isToolCompleted(payload)) {
|
|
459
|
+
if (eventName === "tool.completed" || isToolCompleted(eventName, payload)) {
|
|
456
460
|
return { raw: { event: eventName, payload }, kind: "tool_result", seq };
|
|
457
461
|
}
|
|
458
|
-
if (eventName === "item.delta" && payload
|
|
462
|
+
if (eventName === "item.delta" && isAgentMessageDelta(payload)) {
|
|
459
463
|
return { raw: { event: eventName, payload }, kind: "assistant_text", seq };
|
|
460
464
|
}
|
|
465
|
+
if (eventName === "item.completed" && isAgentReasoningItem(payload)) {
|
|
466
|
+
return { raw: { event: eventName, payload }, kind: "thinking", seq };
|
|
467
|
+
}
|
|
461
468
|
if (eventName === "turn.started" || eventName === "status" || embeddedDeepseekEvent(payload) === "turn.started") {
|
|
462
469
|
return { raw: { event: eventName, payload }, kind: "system", seq };
|
|
463
470
|
}
|
|
@@ -485,18 +492,36 @@ function isDeepseekTerminalEvent(eventName: string, payload: any): boolean {
|
|
|
485
492
|
);
|
|
486
493
|
}
|
|
487
494
|
|
|
488
|
-
function isToolStarted(payload: any): boolean {
|
|
489
|
-
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
|
+
);
|
|
490
500
|
}
|
|
491
501
|
|
|
492
|
-
function isToolCompleted(payload: any): boolean {
|
|
493
|
-
const kind = payload?.payload?.item?.kind;
|
|
502
|
+
function isToolCompleted(eventName: string, payload: any): boolean {
|
|
503
|
+
const kind = payload?.payload?.item?.kind ?? payload?.item?.kind;
|
|
494
504
|
return (
|
|
495
|
-
(
|
|
505
|
+
(eventName === "item.completed" ||
|
|
506
|
+
eventName === "item.failed" ||
|
|
507
|
+
payload?.event === "item.completed" ||
|
|
508
|
+
payload?.event === "item.failed") &&
|
|
496
509
|
(kind === "tool_call" || kind === "file_change" || kind === "command_execution")
|
|
497
510
|
);
|
|
498
511
|
}
|
|
499
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
|
+
|
|
500
525
|
function extractDeepseekError(eventName: string, payload: any): string | undefined {
|
|
501
526
|
if (eventName === "error") {
|
|
502
527
|
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). */
|
package/src/gateway-control.ts
CHANGED
|
@@ -313,13 +313,17 @@ export function createGatewayControl(ctx: GatewayControlContext) {
|
|
|
313
313
|
if (!loginId) {
|
|
314
314
|
return badParams("upsert_gateway: wechat requires loginId");
|
|
315
315
|
}
|
|
316
|
-
const
|
|
317
|
-
if (
|
|
316
|
+
const resolved = sessions.resolve(loginId);
|
|
317
|
+
if (resolved.state !== "live") {
|
|
318
318
|
return {
|
|
319
319
|
ok: false,
|
|
320
|
-
error:
|
|
320
|
+
error:
|
|
321
|
+
resolved.state === "missing"
|
|
322
|
+
? { code: "login_missing", message: `wechat login session "${loginId}" not found` }
|
|
323
|
+
: { code: "login_expired", message: `wechat login session "${loginId}" expired` },
|
|
321
324
|
};
|
|
322
325
|
}
|
|
326
|
+
const session = resolved.session!;
|
|
323
327
|
if (session.provider !== "wechat") {
|
|
324
328
|
return badParams(`upsert_gateway: login session provider "${session.provider}" != "wechat"`);
|
|
325
329
|
}
|
|
@@ -347,13 +351,17 @@ export function createGatewayControl(ctx: GatewayControlContext) {
|
|
|
347
351
|
if (!loginId) {
|
|
348
352
|
return badParams("upsert_gateway: feishu requires loginId");
|
|
349
353
|
}
|
|
350
|
-
const
|
|
351
|
-
if (
|
|
354
|
+
const resolved = sessions.resolve(loginId);
|
|
355
|
+
if (resolved.state !== "live") {
|
|
352
356
|
return {
|
|
353
357
|
ok: false,
|
|
354
|
-
error:
|
|
358
|
+
error:
|
|
359
|
+
resolved.state === "missing"
|
|
360
|
+
? { code: "login_missing", message: `feishu login session "${loginId}" not found` }
|
|
361
|
+
: { code: "login_expired", message: `feishu login session "${loginId}" expired` },
|
|
355
362
|
};
|
|
356
363
|
}
|
|
364
|
+
const session = resolved.session!;
|
|
357
365
|
if (session.provider !== "feishu") {
|
|
358
366
|
return badParams(`upsert_gateway: login session provider "${session.provider}" != "feishu"`);
|
|
359
367
|
}
|
|
@@ -869,13 +877,17 @@ export function createGatewayControl(ctx: GatewayControlContext) {
|
|
|
869
877
|
if (!params.accountId || typeof params.accountId !== "string") {
|
|
870
878
|
return badParams("gateway_recent_senders: accountId is required");
|
|
871
879
|
}
|
|
872
|
-
const
|
|
873
|
-
if (
|
|
880
|
+
const resolved = sessions.resolve(params.loginId);
|
|
881
|
+
if (resolved.state !== "live") {
|
|
874
882
|
return {
|
|
875
883
|
ok: false,
|
|
876
|
-
error:
|
|
884
|
+
error:
|
|
885
|
+
resolved.state === "missing"
|
|
886
|
+
? { code: "login_missing", message: `wechat login session "${params.loginId}" not found` }
|
|
887
|
+
: { code: "login_expired", message: `wechat login session "${params.loginId}" expired` },
|
|
877
888
|
};
|
|
878
889
|
}
|
|
890
|
+
const session = resolved.session!;
|
|
879
891
|
if (session.provider !== "wechat") {
|
|
880
892
|
return badParams("gateway_recent_senders: provider does not match login session");
|
|
881
893
|
}
|