typeclaw 0.8.0 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -6
- package/package.json +5 -3
- package/scripts/require-parallel.ts +41 -0
- package/src/agent/index.ts +55 -6
- package/src/agent/live-sessions.ts +34 -0
- package/src/agent/plugin-tools.ts +2 -0
- package/src/agent/session-meta.ts +21 -2
- package/src/agent/subagent-completion-reminder.ts +89 -0
- package/src/agent/subagents.ts +75 -15
- package/src/agent/system-prompt.ts +10 -8
- package/src/agent/tools/channel-reply.ts +47 -7
- package/src/agent/tools/channel-send.ts +43 -11
- package/src/agent/tools/runtime-notice.ts +41 -0
- package/src/bundled-plugins/explorer/explorer.ts +2 -2
- package/src/bundled-plugins/guard/index.ts +14 -1
- package/src/bundled-plugins/guard/policies/managed-config.ts +43 -13
- package/src/bundled-plugins/guard/policies/memory-retrieval-cache-write.ts +37 -0
- package/src/bundled-plugins/guard/policies/memory-topics-delete.ts +67 -0
- package/src/bundled-plugins/guard/policies/memory-topics-write.ts +33 -0
- package/src/bundled-plugins/guard/policies/non-workspace-write.ts +8 -2
- package/src/bundled-plugins/guard/policy.ts +7 -0
- package/src/bundled-plugins/memory/README.md +76 -62
- package/src/bundled-plugins/memory/append-tool.ts +3 -2
- package/src/bundled-plugins/memory/citation-superset.ts +49 -11
- package/src/bundled-plugins/memory/citations.ts +19 -8
- package/src/bundled-plugins/memory/delete-tool.ts +57 -0
- package/src/bundled-plugins/memory/dreaming-state.ts +1 -1
- package/src/bundled-plugins/memory/dreaming.ts +364 -146
- package/src/bundled-plugins/memory/frontmatter.ts +165 -0
- package/src/bundled-plugins/memory/index.ts +257 -16
- package/src/bundled-plugins/memory/injection-plan.ts +15 -0
- package/src/bundled-plugins/memory/load-memory.ts +102 -103
- package/src/bundled-plugins/memory/load-shards.ts +156 -0
- package/src/bundled-plugins/memory/memory-logger.ts +16 -15
- package/src/bundled-plugins/memory/memory-retrieval.ts +111 -0
- package/src/bundled-plugins/memory/migration.ts +353 -1
- package/src/bundled-plugins/memory/paths.ts +42 -0
- package/src/bundled-plugins/memory/search-tool.ts +232 -0
- package/src/bundled-plugins/memory/secret-detector.ts +2 -2
- package/src/bundled-plugins/memory/shard-snapshot.ts +51 -0
- package/src/bundled-plugins/memory/slug.ts +59 -0
- package/src/bundled-plugins/memory/stream-io.ts +110 -1
- package/src/bundled-plugins/memory/strength.ts +3 -3
- package/src/bundled-plugins/memory/topics.ts +70 -16
- package/src/bundled-plugins/security/index.ts +24 -0
- package/src/bundled-plugins/security/permissions.ts +4 -0
- package/src/bundled-plugins/security/policies/cron-promotion.ts +349 -0
- package/src/bundled-plugins/security/policies/git-exfil.ts +2 -0
- package/src/bundled-plugins/security/policies/prompt-injection.ts +3 -0
- package/src/bundled-plugins/security/policies/role-promotion.ts +419 -0
- package/src/bundled-plugins/security/policies/system-prompt-leak.ts +1 -0
- package/src/channels/adapters/kakaotalk-attachment.ts +7 -17
- package/src/channels/adapters/kakaotalk-classify.ts +4 -1
- package/src/channels/adapters/kakaotalk.ts +65 -38
- package/src/channels/adapters/slack-bot-classify.ts +2 -27
- package/src/channels/index.ts +5 -0
- package/src/channels/router.ts +320 -22
- package/src/channels/subagent-completion-bridge.ts +84 -0
- package/src/cli/builtins.ts +1 -0
- package/src/cli/index.ts +1 -0
- package/src/cli/init.ts +122 -14
- package/src/cli/inspect.ts +151 -0
- package/src/cron/consumer.ts +1 -1
- package/src/init/dockerfile.ts +268 -4
- package/src/init/hatching.ts +5 -6
- package/src/init/kakaotalk-auth.ts +6 -47
- package/src/init/validate-api-key.ts +121 -0
- package/src/inspect/index.ts +213 -0
- package/src/inspect/label.ts +50 -0
- package/src/inspect/live.ts +221 -0
- package/src/inspect/render.ts +163 -0
- package/src/inspect/replay.ts +295 -0
- package/src/inspect/session-list.ts +160 -0
- package/src/inspect/types.ts +110 -0
- package/src/plugin/hooks.ts +23 -1
- package/src/plugin/index.ts +2 -0
- package/src/plugin/manager.ts +1 -1
- package/src/plugin/registry.ts +1 -1
- package/src/plugin/types.ts +10 -0
- package/src/run/channel-session-factory.ts +7 -1
- package/src/run/index.ts +103 -21
- package/src/secrets/kakao-renewal.ts +3 -47
- package/src/server/index.ts +241 -60
- package/src/shared/index.ts +3 -0
- package/src/shared/protocol.ts +49 -0
- package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +9 -9
- package/src/skills/typeclaw-claude-code/SKILL.md +57 -39
- package/src/skills/typeclaw-claude-code/references/stop-hook.md +2 -0
- package/src/skills/typeclaw-claude-code/references/tmux-driving.md +102 -16
- package/src/skills/typeclaw-config/SKILL.md +1 -1
- package/src/skills/typeclaw-cron/SKILL.md +1 -1
- package/src/skills/typeclaw-memory/SKILL.md +16 -163
- package/src/skills/typeclaw-permissions/SKILL.md +2 -2
- package/src/skills/typeclaw-plugins/SKILL.md +25 -14
- package/src/test-helpers/wait-for.ts +7 -1
- package/typeclaw.schema.json +15 -1
|
@@ -6,33 +6,8 @@ import type { InboundMessage } from '@/channels/types'
|
|
|
6
6
|
|
|
7
7
|
import { slackTsToMillis } from './slack-bot-time'
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
// load-bearing for this adapter:
|
|
12
|
-
// - `parent_user_id`: set on every reply within a thread; identifies the
|
|
13
|
-
// author of the message the thread is rooted at. Used to decide whether
|
|
14
|
-
// a reply targets the bot, another human, or an unknown parent.
|
|
15
|
-
// - `client_msg_id`: client-generated UUID on user-authored messages,
|
|
16
|
-
// stable across Slack-side resends of the same gesture. Primary dedupe
|
|
17
|
-
// key for the "one user action surfaces as two events" case.
|
|
18
|
-
// - `files`: attachments delivered inline on the same message event (Slack
|
|
19
|
-
// does not fire a separate file_share for messages we receive).
|
|
20
|
-
// Typing them here (rather than reading them via `as` casts at every call
|
|
21
|
-
// site) keeps the classifier readable and makes it the single source of
|
|
22
|
-
// truth for "what Slack actually sends" — anything else reading these
|
|
23
|
-
// fields imports `SlackInboundMessageEvent` from this module.
|
|
24
|
-
export type SlackInboundMessageEvent = SlackSocketModeMessageEvent & {
|
|
25
|
-
parent_user_id?: string
|
|
26
|
-
client_msg_id?: string
|
|
27
|
-
files?: SlackFile[]
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
// `app_mention` envelopes do not always carry `client_msg_id`, but typing
|
|
31
|
-
// it keeps the promotion to a message-shaped event lossless if Slack
|
|
32
|
-
// starts sending it. Same reasoning as `SlackInboundMessageEvent` above.
|
|
33
|
-
export type SlackInboundAppMentionEvent = SlackSocketModeAppMentionEvent & {
|
|
34
|
-
client_msg_id?: string
|
|
35
|
-
}
|
|
9
|
+
export type SlackInboundMessageEvent = SlackSocketModeMessageEvent
|
|
10
|
+
export type SlackInboundAppMentionEvent = SlackSocketModeAppMentionEvent
|
|
36
11
|
|
|
37
12
|
export type InboundDropReason =
|
|
38
13
|
| 'self_author' // event.user === botUserId; we never route our own messages back to ourselves
|
package/src/channels/index.ts
CHANGED
|
@@ -9,6 +9,11 @@ export {
|
|
|
9
9
|
type CreateSessionForChannel,
|
|
10
10
|
} from './router'
|
|
11
11
|
export { createChannelsReloadable } from './reloadable'
|
|
12
|
+
export {
|
|
13
|
+
createSubagentCompletionBridge,
|
|
14
|
+
type SubagentCompletionBridge,
|
|
15
|
+
type SubagentCompletionBridgeOptions,
|
|
16
|
+
} from './subagent-completion-bridge'
|
|
12
17
|
export {
|
|
13
18
|
channelsSchema,
|
|
14
19
|
ADAPTER_IDS,
|
package/src/channels/router.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { SessionManager } from '@mariozechner/pi-coding-agent'
|
|
|
6
6
|
import { createSession, type AgentSession } from '@/agent'
|
|
7
7
|
import { subscribeProviderErrors } from '@/agent/provider-error'
|
|
8
8
|
import type { ChannelParticipant, SessionOrigin } from '@/agent/session-origin'
|
|
9
|
+
import { renderSubagentCompletionReminder } from '@/agent/subagent-completion-reminder'
|
|
9
10
|
import { createCommandRegistry } from '@/commands'
|
|
10
11
|
import { CORE_PERMISSIONS, type PermissionService } from '@/permissions'
|
|
11
12
|
import type { HookBus } from '@/plugin'
|
|
@@ -254,6 +255,14 @@ type LiveSession = {
|
|
|
254
255
|
currentTurnAuthorId: string | null
|
|
255
256
|
currentTurnAuthorIds: Set<string>
|
|
256
257
|
lastTurnAuthorIds: Set<string>
|
|
258
|
+
// Mirror of currentTurnAuthorId at end-of-turn (the LAST speaker of the
|
|
259
|
+
// prior batch), preserved across the drain finally-block which resets
|
|
260
|
+
// currentTurnAuthorId to null. Read by the reminder-only branch in
|
|
261
|
+
// drain() so a system-reminder wakeup carries the same author the prior
|
|
262
|
+
// turn's tool.before saw — matching "last speaker" semantics (not "first
|
|
263
|
+
// inserted into Set"), so a multi-author prior turn like alice→bob
|
|
264
|
+
// restores `bob`, the same identity normal turns would have used.
|
|
265
|
+
lastTurnAuthorId: string | null
|
|
257
266
|
consecutiveAborts: number
|
|
258
267
|
// Per-(chat:thread) count of bot messages sent without intervening user
|
|
259
268
|
// input being rendered into the model's context. Reset at the top of each
|
|
@@ -261,6 +270,15 @@ type LiveSession = {
|
|
|
261
270
|
// about to be shown to the model). channel_send reads this BEFORE calling
|
|
262
271
|
// router.send so the hint reflects the position of the about-to-happen send
|
|
263
272
|
// (n-th in a row), nudging the model to yield without forcing it to.
|
|
273
|
+
// Queue of `<system-reminder>...</system-reminder>` strings to prepend
|
|
274
|
+
// into the next turn's user-message body. Populated by
|
|
275
|
+
// `injectSubagentCompletionReminder` (and any future system-injected
|
|
276
|
+
// wakeups) so a backgrounded subagent's completion can wake a channel
|
|
277
|
+
// session that has no pending user inbounds. Drained at the top of
|
|
278
|
+
// every `drain()` iteration alongside the regular promptQueue batch;
|
|
279
|
+
// the drain loop's run condition checks BOTH queues so a system
|
|
280
|
+
// reminder alone is enough to trigger a turn.
|
|
281
|
+
pendingSystemReminders: string[]
|
|
264
282
|
consecutiveSends: Map<string, number>
|
|
265
283
|
// Per-(chat:thread) text of the last reserved bot send. Set
|
|
266
284
|
// SYNCHRONOUSLY inside router.send before the outbound callback awaits,
|
|
@@ -387,6 +405,21 @@ export type ChannelRouter = {
|
|
|
387
405
|
// slack-bot-classify.ts. Read live so a reload of `alias` propagates
|
|
388
406
|
// to adapters without a restart.
|
|
389
407
|
getSelfAliases: () => readonly string[]
|
|
408
|
+
// Inject a `<system-reminder>` block addressed to a live channel session
|
|
409
|
+
// identified by `parentSessionId`. The reminder is rendered into the
|
|
410
|
+
// next turn's user-message body and triggers a drain even if the
|
|
411
|
+
// promptQueue is empty. Returns `delivered` when a matching live
|
|
412
|
+
// session was found and the reminder was queued, `no-live-session`
|
|
413
|
+
// otherwise. Used by the subagent-completion bridge in
|
|
414
|
+
// src/run/index.ts; safe for tests to call directly via a fake router.
|
|
415
|
+
injectSubagentCompletionReminder: (args: {
|
|
416
|
+
parentSessionId: string
|
|
417
|
+
subagent: string
|
|
418
|
+
taskId: string
|
|
419
|
+
ok: boolean
|
|
420
|
+
durationMs: number
|
|
421
|
+
error?: string
|
|
422
|
+
}) => { kind: 'delivered'; keyId: string } | { kind: 'no-live-session' }
|
|
390
423
|
stop: () => Promise<void>
|
|
391
424
|
liveCount: () => number
|
|
392
425
|
__testing?: {
|
|
@@ -396,6 +429,34 @@ export type ChannelRouter = {
|
|
|
396
429
|
isTypingActive: (key: ChannelKey) => boolean
|
|
397
430
|
stopTyping: (key: ChannelKey) => Promise<void>
|
|
398
431
|
runIdleGc: () => Promise<void>
|
|
432
|
+
// Returns the seeded author state on the live session matching
|
|
433
|
+
// `key`, or undefined when no live session exists. Tests use this
|
|
434
|
+
// to pin the symmetric-seeding invariant between `lastTurnAuthorId`
|
|
435
|
+
// (string) and `lastTurnAuthorIds` (Set) at session creation —
|
|
436
|
+
// observable directly here rather than via a downstream sticky-
|
|
437
|
+
// credit grant test that would need to coordinate with multiple
|
|
438
|
+
// subsystems.
|
|
439
|
+
getLiveAuthorState: (key: ChannelKey) =>
|
|
440
|
+
| {
|
|
441
|
+
currentTurnAuthorId: string | null
|
|
442
|
+
currentTurnAuthorIds: readonly string[]
|
|
443
|
+
lastTurnAuthorId: string | null
|
|
444
|
+
lastTurnAuthorIds: readonly string[]
|
|
445
|
+
}
|
|
446
|
+
| undefined
|
|
447
|
+
// Returns a shallow copy of `live.originRef.current` for the live
|
|
448
|
+
// session matching `key`, or undefined when no live session exists.
|
|
449
|
+
// Exists so tests can assert on the per-turn origin that tool.before
|
|
450
|
+
// consumers would see — the origin is normally only observable
|
|
451
|
+
// indirectly via in-flight tool calls, which the fake session doesn't
|
|
452
|
+
// execute. The shallow copy detaches the top-level fields from
|
|
453
|
+
// `originRef` so a later turn replacing `originRef.current` doesn't
|
|
454
|
+
// change a captured assertion. Nested fields (`participants`,
|
|
455
|
+
// `membership`) are still shared by reference; in practice
|
|
456
|
+
// `updateParticipants` returns a fresh array rather than mutating in
|
|
457
|
+
// place, so observed snapshots are stable for the assertions tests
|
|
458
|
+
// make today. NOT a public router method.
|
|
459
|
+
getLiveOriginSnapshot: (key: ChannelKey) => SessionOrigin | undefined
|
|
399
460
|
}
|
|
400
461
|
}
|
|
401
462
|
|
|
@@ -800,6 +861,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
800
861
|
resolvedNames,
|
|
801
862
|
originRef,
|
|
802
863
|
promptQueue: [],
|
|
864
|
+
pendingSystemReminders: [],
|
|
803
865
|
contextBuffer: [],
|
|
804
866
|
draining: false,
|
|
805
867
|
debounceTimer: null,
|
|
@@ -811,7 +873,18 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
811
873
|
firstUnprocessedAt: 0,
|
|
812
874
|
currentTurnAuthorId: null,
|
|
813
875
|
currentTurnAuthorIds: new Set(),
|
|
814
|
-
|
|
876
|
+
// `lastTurnAuthorId` (string, used for `lastInboundAuthorId` in
|
|
877
|
+
// origin) and `lastTurnAuthorIds` (Set, used by
|
|
878
|
+
// `grantStickyForReplyTargets` as the fallback when
|
|
879
|
+
// `currentTurnAuthorIds` is empty) are seeded TOGETHER from
|
|
880
|
+
// `triggeringAuthorId`. Seeding only the string would leave the
|
|
881
|
+
// Set empty for the cold-start reminder-only path, which is
|
|
882
|
+
// observable when the agent replies during that turn — `send()`
|
|
883
|
+
// would compute an empty `targetIds` and silently drop the
|
|
884
|
+
// sticky-credit grant for the seeded author. The two fields must
|
|
885
|
+
// stay in sync, so they are written in the same statement.
|
|
886
|
+
lastTurnAuthorIds: triggeringAuthorId !== undefined ? new Set([triggeringAuthorId]) : new Set(),
|
|
887
|
+
lastTurnAuthorId: triggeringAuthorId ?? null,
|
|
815
888
|
consecutiveAborts: 0,
|
|
816
889
|
consecutiveSends: new Map(),
|
|
817
890
|
lastSentText: new Map(),
|
|
@@ -1026,12 +1099,13 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1026
1099
|
}
|
|
1027
1100
|
}
|
|
1028
1101
|
|
|
1029
|
-
const fireSessionTurnStart = async (live: LiveSession): Promise<void> => {
|
|
1102
|
+
const fireSessionTurnStart = async (live: LiveSession, userPrompt: string): Promise<void> => {
|
|
1030
1103
|
if (!live.hooks) return
|
|
1031
1104
|
try {
|
|
1032
1105
|
await live.hooks.runSessionTurnStart({
|
|
1033
1106
|
sessionId: live.sessionId,
|
|
1034
1107
|
agentDir: options.agentDir,
|
|
1108
|
+
userPrompt,
|
|
1035
1109
|
origin: buildLiveOrigin(live),
|
|
1036
1110
|
})
|
|
1037
1111
|
} catch (err) {
|
|
@@ -1082,6 +1156,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1082
1156
|
live.debounceTimer = null
|
|
1083
1157
|
live.firstUnprocessedAt = 0
|
|
1084
1158
|
live.promptQueue.length = 0
|
|
1159
|
+
live.pendingSystemReminders.length = 0
|
|
1085
1160
|
await stopTypingHeartbeat(live)
|
|
1086
1161
|
try {
|
|
1087
1162
|
await live.session.abort()
|
|
@@ -1095,7 +1170,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1095
1170
|
if (live.draining || live.destroyed) return
|
|
1096
1171
|
live.draining = true
|
|
1097
1172
|
try {
|
|
1098
|
-
while (live.promptQueue.length > 0 && !live.destroyed) {
|
|
1173
|
+
while ((live.promptQueue.length > 0 || live.pendingSystemReminders.length > 0) && !live.destroyed) {
|
|
1099
1174
|
live.typingTimedOut = false
|
|
1100
1175
|
// Heartbeat must run during generation as well as during debounce.
|
|
1101
1176
|
// Because new inbounds during a turn just push into promptQueue
|
|
@@ -1104,13 +1179,32 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1104
1179
|
startTypingHeartbeat(live)
|
|
1105
1180
|
const batch = live.promptQueue.splice(0, live.promptQueue.length)
|
|
1106
1181
|
const observed = live.contextBuffer.splice(0, live.contextBuffer.length)
|
|
1107
|
-
const
|
|
1182
|
+
const reminders = live.pendingSystemReminders.splice(0, live.pendingSystemReminders.length)
|
|
1183
|
+
const text = composeTurnPrompt(observed, batch, {
|
|
1184
|
+
loopGuardActive: live.loopGuardActive,
|
|
1185
|
+
systemReminders: reminders,
|
|
1186
|
+
})
|
|
1108
1187
|
|
|
1109
|
-
live.currentTurnAuthorId = batch.length > 0 ? batch[batch.length - 1]!.authorId : null
|
|
1110
|
-
live.currentTurnAuthorIds = new Set(batch.map((m) => m.authorId))
|
|
1111
1188
|
if (batch.length > 0) {
|
|
1189
|
+
live.currentTurnAuthorId = batch[batch.length - 1]!.authorId
|
|
1190
|
+
live.currentTurnAuthorIds = new Set(batch.map((m) => m.authorId))
|
|
1112
1191
|
live.consecutiveSends.clear()
|
|
1113
1192
|
live.lastSentText.clear()
|
|
1193
|
+
} else if (live.lastTurnAuthorId !== null) {
|
|
1194
|
+
// Reminder-only turn (batch.length === 0, reminders.length > 0):
|
|
1195
|
+
// restore the author identity from the prior turn so author-
|
|
1196
|
+
// scoped role resolution still works on this turn. The drain
|
|
1197
|
+
// finally-block clears `currentTurnAuthorId` between turns, so a
|
|
1198
|
+
// reminder arriving while the session is idle would otherwise
|
|
1199
|
+
// strip `lastInboundAuthorId` from the tool.before origin and
|
|
1200
|
+
// demote roles like `slack:T0/C0 author:U_OWNER` to whichever
|
|
1201
|
+
// non-author rule matches — silently breaking the channel_reply
|
|
1202
|
+
// that the reminder is asking the agent to send. `lastTurnAuthorId`
|
|
1203
|
+
// tracks the LAST speaker of the prior batch (matching normal-
|
|
1204
|
+
// turn `batch[batch.length - 1]!.authorId` semantics) so a multi-
|
|
1205
|
+
// author prior turn like alice→bob restores `bob`, not alice.
|
|
1206
|
+
live.currentTurnAuthorId = live.lastTurnAuthorId
|
|
1207
|
+
live.currentTurnAuthorIds = new Set(live.lastTurnAuthorIds)
|
|
1114
1208
|
}
|
|
1115
1209
|
|
|
1116
1210
|
// Update the live origin holder so this turn's tool.before events
|
|
@@ -1127,7 +1221,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1127
1221
|
logger.info(`[channels] ${live.keyId} prompting batch=${batch.length} text_len=${text.length}`)
|
|
1128
1222
|
const promptStart = now()
|
|
1129
1223
|
const successfulSendsBeforePrompt = live.successfulChannelSends
|
|
1130
|
-
await fireSessionTurnStart(live)
|
|
1224
|
+
await fireSessionTurnStart(live, text)
|
|
1131
1225
|
try {
|
|
1132
1226
|
await live.session.prompt(text)
|
|
1133
1227
|
await validateChannelTurn(live, successfulSendsBeforePrompt)
|
|
@@ -1142,6 +1236,9 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1142
1236
|
}
|
|
1143
1237
|
await fireSessionIdle(live)
|
|
1144
1238
|
live.lastTurnAuthorIds = new Set(live.currentTurnAuthorIds)
|
|
1239
|
+
if (live.currentTurnAuthorId !== null) {
|
|
1240
|
+
live.lastTurnAuthorId = live.currentTurnAuthorId
|
|
1241
|
+
}
|
|
1145
1242
|
}
|
|
1146
1243
|
} finally {
|
|
1147
1244
|
live.draining = false
|
|
@@ -1645,8 +1742,9 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1645
1742
|
const assistantText = latestAssistantText(live.session)
|
|
1646
1743
|
if (assistantText === null) return
|
|
1647
1744
|
|
|
1648
|
-
if (
|
|
1649
|
-
|
|
1745
|
+
if (endsWithNoReplySignal(assistantText)) {
|
|
1746
|
+
const leakedReasoning = !isNoReplySignal(assistantText)
|
|
1747
|
+
logger.info(`[channels] ${live.keyId} no_reply${leakedReasoning ? ' (with_leaked_reasoning)' : ''}`)
|
|
1650
1748
|
return
|
|
1651
1749
|
}
|
|
1652
1750
|
|
|
@@ -1657,6 +1755,11 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1657
1755
|
return
|
|
1658
1756
|
}
|
|
1659
1757
|
|
|
1758
|
+
if (isLikelyKimiChannelToolLeak(assistantText)) {
|
|
1759
|
+
logger.warn(`[channels] ${live.keyId}: suppressed kimi_tool_call_leak text_len=${assistantText.length}`)
|
|
1760
|
+
return
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1660
1763
|
logger.warn(
|
|
1661
1764
|
`[channels] ${live.keyId}: recovering assistant_text_without_channel_tool text_len=${assistantText.length}`,
|
|
1662
1765
|
)
|
|
@@ -1743,6 +1846,14 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1743
1846
|
if (live.destroyed) continue
|
|
1744
1847
|
if (live.draining) continue
|
|
1745
1848
|
if (live.promptQueue.length > 0) continue
|
|
1849
|
+
// pendingSystemReminders is checked alongside promptQueue because both
|
|
1850
|
+
// represent pending work that drain() will process. Today's only
|
|
1851
|
+
// populator (injectSubagentCompletionReminder) also fires drain()
|
|
1852
|
+
// synchronously, which sets draining=true and shadows this guard via
|
|
1853
|
+
// the line above — but the guard exists to keep the invariant honest
|
|
1854
|
+
// for any future caller that queues a reminder without immediately
|
|
1855
|
+
// waking the drain loop.
|
|
1856
|
+
if (live.pendingSystemReminders.length > 0) continue
|
|
1746
1857
|
if (t - live.lastInboundAt <= SESSION_IDLE_MS) continue
|
|
1747
1858
|
victims.push(live)
|
|
1748
1859
|
}
|
|
@@ -1812,6 +1923,45 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1812
1923
|
return { kind: 'unknown-command', name: lowered }
|
|
1813
1924
|
}
|
|
1814
1925
|
|
|
1926
|
+
const injectSubagentCompletionReminder = (args: {
|
|
1927
|
+
parentSessionId: string
|
|
1928
|
+
subagent: string
|
|
1929
|
+
taskId: string
|
|
1930
|
+
ok: boolean
|
|
1931
|
+
durationMs: number
|
|
1932
|
+
error?: string
|
|
1933
|
+
}): { kind: 'delivered'; keyId: string } | { kind: 'no-live-session' } => {
|
|
1934
|
+
for (const live of liveSessions.values()) {
|
|
1935
|
+
if (live.destroyed) continue
|
|
1936
|
+
if (live.sessionId !== args.parentSessionId) continue
|
|
1937
|
+
const text = renderSubagentCompletionReminder({
|
|
1938
|
+
subagent: args.subagent,
|
|
1939
|
+
taskId: args.taskId,
|
|
1940
|
+
ok: args.ok,
|
|
1941
|
+
durationMs: args.durationMs,
|
|
1942
|
+
...(args.error !== undefined ? { error: args.error } : {}),
|
|
1943
|
+
channel: true,
|
|
1944
|
+
})
|
|
1945
|
+
live.pendingSystemReminders.push(text)
|
|
1946
|
+
logger.info(`[channels] ${live.keyId}: subagent-completion reminder queued task=${args.taskId} ok=${args.ok}`)
|
|
1947
|
+
// Wake the drain loop. If a turn is already in flight, the wakeup is
|
|
1948
|
+
// a no-op because drain() will pick up the reminder on its next
|
|
1949
|
+
// iteration (it now gates on promptQueue OR pendingSystemReminders).
|
|
1950
|
+
// If the session is idle, fire drain() immediately rather than going
|
|
1951
|
+
// through the debounce path — the reminder is not a user inbound,
|
|
1952
|
+
// so the "coalesce nearby inbounds" rationale for debouncing does
|
|
1953
|
+
// not apply. Mirrors the TUI path's `idle ? 'interrupt' : 'queue'`
|
|
1954
|
+
// semantics: the channel router doesn't have a `delivery: interrupt`
|
|
1955
|
+
// mechanism (no in-flight abort during a turn), but firing drain()
|
|
1956
|
+
// immediately is the equivalent for an idle session.
|
|
1957
|
+
if (!live.draining) {
|
|
1958
|
+
void drain(live)
|
|
1959
|
+
}
|
|
1960
|
+
return { kind: 'delivered', keyId: live.keyId }
|
|
1961
|
+
}
|
|
1962
|
+
return { kind: 'no-live-session' }
|
|
1963
|
+
}
|
|
1964
|
+
|
|
1815
1965
|
return {
|
|
1816
1966
|
route,
|
|
1817
1967
|
send,
|
|
@@ -1833,6 +1983,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1833
1983
|
fetchAttachment,
|
|
1834
1984
|
executeCommand,
|
|
1835
1985
|
getSelfAliases: computeSelfAliases,
|
|
1986
|
+
injectSubagentCompletionReminder,
|
|
1836
1987
|
stop,
|
|
1837
1988
|
liveCount: () => liveSessions.size,
|
|
1838
1989
|
__testing: {
|
|
@@ -1876,6 +2027,22 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1876
2027
|
await stopTypingHeartbeat(live)
|
|
1877
2028
|
},
|
|
1878
2029
|
runIdleGc,
|
|
2030
|
+
getLiveOriginSnapshot: (key: ChannelKey) => {
|
|
2031
|
+
const live = liveSessions.get(channelKeyId(key))
|
|
2032
|
+
const origin = live?.originRef.current
|
|
2033
|
+
if (origin === undefined) return undefined
|
|
2034
|
+
return { ...origin }
|
|
2035
|
+
},
|
|
2036
|
+
getLiveAuthorState: (key: ChannelKey) => {
|
|
2037
|
+
const live = liveSessions.get(channelKeyId(key))
|
|
2038
|
+
if (live === undefined) return undefined
|
|
2039
|
+
return {
|
|
2040
|
+
currentTurnAuthorId: live.currentTurnAuthorId,
|
|
2041
|
+
currentTurnAuthorIds: Array.from(live.currentTurnAuthorIds),
|
|
2042
|
+
lastTurnAuthorId: live.lastTurnAuthorId,
|
|
2043
|
+
lastTurnAuthorIds: Array.from(live.lastTurnAuthorIds),
|
|
2044
|
+
}
|
|
2045
|
+
},
|
|
1879
2046
|
},
|
|
1880
2047
|
}
|
|
1881
2048
|
}
|
|
@@ -1883,27 +2050,50 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1883
2050
|
function composeTurnPrompt(
|
|
1884
2051
|
observed: readonly ObservedInbound[],
|
|
1885
2052
|
batch: readonly QueuedInbound[],
|
|
1886
|
-
state: { loopGuardActive: boolean } = { loopGuardActive: false },
|
|
2053
|
+
state: { loopGuardActive: boolean; systemReminders?: readonly string[] } = { loopGuardActive: false },
|
|
1887
2054
|
): string {
|
|
1888
2055
|
const parts: string[] = []
|
|
2056
|
+
// System reminders (subagent-completion wakeups today) lead the turn body
|
|
2057
|
+
// because they are typically what triggered the drain — when the prompt
|
|
2058
|
+
// queue is empty and the only thing in this iteration is a reminder, the
|
|
2059
|
+
// model needs to see the reminder before any optional context. The
|
|
2060
|
+
// reminder block is self-fenced by its <system-reminder> tags, so no
|
|
2061
|
+
// extra framing is needed and the model already learns this shape from
|
|
2062
|
+
// the TUI path; channel sessions see the same tags.
|
|
2063
|
+
if (state.systemReminders && state.systemReminders.length > 0) {
|
|
2064
|
+
for (const reminder of state.systemReminders) {
|
|
2065
|
+
parts.push(reminder)
|
|
2066
|
+
}
|
|
2067
|
+
parts.push('')
|
|
2068
|
+
}
|
|
1889
2069
|
// Loop-guard notice lives in the user-turn text (recomposed every drain)
|
|
1890
2070
|
// rather than in the system prompt so it does not invalidate the
|
|
1891
2071
|
// prompt-prefix cache. The cached prefix covers system + tools + earlier
|
|
1892
2072
|
// turns; the current user-turn suffix is non-cacheable by design, so
|
|
1893
2073
|
// adding a section here is cache-neutral.
|
|
1894
2074
|
//
|
|
1895
|
-
// SYSTEM MESSAGE convention: any runtime-injected block in the user
|
|
1896
|
-
// that is NOT from a chat participant
|
|
1897
|
-
// `**[SYSTEM MESSAGE — not from a human]**` framing fenced by
|
|
1898
|
-
// rules (`---`)
|
|
1899
|
-
//
|
|
2075
|
+
// SYSTEM MESSAGE convention: any runtime-injected block in the user
|
|
2076
|
+
// turn that is NOT from a chat participant MUST use the
|
|
2077
|
+
// `**[SYSTEM MESSAGE — not from a human]**` framing fenced by
|
|
2078
|
+
// horizontal rules (`---`) — the loop-guard block below is the
|
|
2079
|
+
// canonical example. This is structurally distinct from the H2
|
|
2080
|
+
// sections used for actual conversation content (`## Recent context`,
|
|
1900
2081
|
// `## Current message`). Without the fencing, models — especially
|
|
1901
2082
|
// persona-rich ones like Kimi — read the heading as a human-authored
|
|
1902
2083
|
// instruction and reply to it ("알겠습니다, 대화 여기까지 할게요"). The
|
|
1903
|
-
// bracketed marker plus the explicit "Do not acknowledge or reply to
|
|
1904
|
-
// notice" line is the trust boundary that prevents this. New
|
|
1905
|
-
// notices (rate-limit, schema-mismatch, abort signals, etc.)
|
|
1906
|
-
// this
|
|
2084
|
+
// bracketed marker plus the explicit "Do not acknowledge or reply to
|
|
2085
|
+
// this notice" line is the trust boundary that prevents this. New
|
|
2086
|
+
// runtime notices (rate-limit, schema-mismatch, abort signals, etc.)
|
|
2087
|
+
// MUST follow this convention.
|
|
2088
|
+
//
|
|
2089
|
+
// ONE narrow exception exists: subagent-completion reminders use
|
|
2090
|
+
// `<system-reminder>...</system-reminder>` tags (prepended above) for
|
|
2091
|
+
// parity with the TUI path's identical tagging (see
|
|
2092
|
+
// `renderSubagentCompletionReminder` in
|
|
2093
|
+
// `src/agent/subagent-completion-reminder.ts`) so the model sees the
|
|
2094
|
+
// same shape across origins. The exception is scoped to that single
|
|
2095
|
+
// case: do NOT extend it to new notice types. Anything that is not
|
|
2096
|
+
// a true subagent-style completion ping uses framing 1.
|
|
1907
2097
|
if (state.loopGuardActive) {
|
|
1908
2098
|
parts.push(
|
|
1909
2099
|
'---',
|
|
@@ -1930,10 +2120,23 @@ function composeTurnPrompt(
|
|
|
1930
2120
|
parts.push(formatAuthorLine(o.ts, o.authorId, o.authorName, o.authorIsBot, o.text))
|
|
1931
2121
|
}
|
|
1932
2122
|
parts.push('')
|
|
1933
|
-
parts.push(batch.length === 1 ? '## Current message (addressed to you)' : '## Current messages (addressed to you)')
|
|
1934
2123
|
}
|
|
1935
|
-
|
|
1936
|
-
|
|
2124
|
+
// Only emit the `## Current message(s)` header when there is at least one
|
|
2125
|
+
// queued inbound to live under it. A reminder-only wakeup (subagent
|
|
2126
|
+
// completion firing while the prompt queue is empty) used to print the
|
|
2127
|
+
// header with zero lines underneath; persona-rich models read the empty
|
|
2128
|
+
// header as "there must be a current message addressed to me" and
|
|
2129
|
+
// hallucinated content to reply to. The header is now batch-gated; the
|
|
2130
|
+
// reminder block above and any observed context still render normally.
|
|
2131
|
+
if (batch.length > 0) {
|
|
2132
|
+
if (observed.length > 0) {
|
|
2133
|
+
parts.push(
|
|
2134
|
+
batch.length === 1 ? '## Current message (addressed to you)' : '## Current messages (addressed to you)',
|
|
2135
|
+
)
|
|
2136
|
+
}
|
|
2137
|
+
for (const b of batch) {
|
|
2138
|
+
parts.push(formatAuthorLine(b.ts, b.authorId, b.authorName, b.authorIsBot, b.text))
|
|
2139
|
+
}
|
|
1937
2140
|
}
|
|
1938
2141
|
return parts.join('\n')
|
|
1939
2142
|
}
|
|
@@ -2133,6 +2336,45 @@ export function isNoReplySignal(text: string): boolean {
|
|
|
2133
2336
|
return false
|
|
2134
2337
|
}
|
|
2135
2338
|
|
|
2339
|
+
// Looser sibling of isNoReplySignal, used ONLY by validateChannelTurn's
|
|
2340
|
+
// recovery path. Catches leaked-reasoning turns where the model produced
|
|
2341
|
+
// prose and then ended with the silent-turn token, e.g.
|
|
2342
|
+
// "The user is laughing. ... I'll end with NO_REPLY.NO_REPLY"
|
|
2343
|
+
// Today those fall through to recovery and the entire reasoning paragraph
|
|
2344
|
+
// gets posted to the channel — the worst-possible outcome, since the leaked
|
|
2345
|
+
// prose is itself an admission that the model intended to stay silent.
|
|
2346
|
+
//
|
|
2347
|
+
// NOT shared with channel_send / channel_reply misuse guards: those need
|
|
2348
|
+
// strict literal match so a legitimate message like "set NO_REPLY=true in
|
|
2349
|
+
// the env" isn't rejected as a misuse of the silent-turn signal. Recovery
|
|
2350
|
+
// is a different question — by the time we get here the model already
|
|
2351
|
+
// failed to call the tool, and "ends in NO_REPLY" is strong evidence of
|
|
2352
|
+
// intent to stay silent, not of intent to send those bytes.
|
|
2353
|
+
//
|
|
2354
|
+
// Matches (returns true):
|
|
2355
|
+
// "NO_REPLY" (strict)
|
|
2356
|
+
// "(NO_REPLY)" (strict, parenthesized)
|
|
2357
|
+
// "... I'll end with NO_REPLY" (trailing token after whitespace)
|
|
2358
|
+
// "... end with NO_REPLY." (+ sentence punctuation)
|
|
2359
|
+
// "... end with NO_REPLY.NO_REPLY" (model-doubled terminator, glued)
|
|
2360
|
+
// "... and stop. (NO_REPLY)" (parenthesized at end)
|
|
2361
|
+
// Does not match (returns false):
|
|
2362
|
+
// "NO_REPLY means do nothing" (token at start, prose after)
|
|
2363
|
+
// "the env var is NO_REPLY_MODE" (substring, not whole token)
|
|
2364
|
+
// "no reply needed" (case-sensitive on purpose)
|
|
2365
|
+
export function endsWithNoReplySignal(text: string): boolean {
|
|
2366
|
+
if (isNoReplySignal(text)) return true
|
|
2367
|
+
const trimmed = text.trim()
|
|
2368
|
+
if (trimmed === '') return false
|
|
2369
|
+
// Strip trailing sentence punctuation / closing brackets / whitespace, then
|
|
2370
|
+
// check the last whitespace-or-punctuation-separated token. The leading
|
|
2371
|
+
// boundary in the regex (`[\s.!?([]`) treats `.NO_REPLY` as a separate
|
|
2372
|
+
// token from the preceding sentence, which covers the model-doubled
|
|
2373
|
+
// `...NO_REPLY.NO_REPLY` shape.
|
|
2374
|
+
const tail = trimmed.replace(/[.!?)\]\s]+$/, '')
|
|
2375
|
+
return /(?:^|[\s.!?([])\(?NO_REPLY\)?$/.test(tail)
|
|
2376
|
+
}
|
|
2377
|
+
|
|
2136
2378
|
// Detects the upstream "empty response" debug sentinel: when the LLM ends a
|
|
2137
2379
|
// turn with only a `thinking` block, some provider SDK paths (observed
|
|
2138
2380
|
// against claude-opus-4-5 via pi-ai) fabricate a single text block whose
|
|
@@ -2158,6 +2400,62 @@ export function isUpstreamEmptyResponseSentinel(text: string): boolean {
|
|
|
2158
2400
|
return trimmed.includes("'stop_reason'")
|
|
2159
2401
|
}
|
|
2160
2402
|
|
|
2403
|
+
// Detects any Kimi-family tool-call delimiter token. Kimi-family deployments
|
|
2404
|
+
// emit tool calls inline in their native chat template using these tokens:
|
|
2405
|
+
//
|
|
2406
|
+
// <|tool_calls_section_begin|>
|
|
2407
|
+
// <|tool_call_begin|>functions.<name>:<idx><|tool_call_argument_begin|>{...}<|tool_call_end|>
|
|
2408
|
+
// <|tool_calls_section_end|>
|
|
2409
|
+
//
|
|
2410
|
+
// (Source: https://github.com/MoonshotAI/Kimi-K2/blob/1b4022b/docs/tool_call_guidance.md;
|
|
2411
|
+
// the documented set is exactly five tokens — the section begin/end markers,
|
|
2412
|
+
// the per-call begin/end markers, and the argument-begin separator. There is
|
|
2413
|
+
// no `<|tool_call_argument_end|>`: arguments terminate at `<|tool_call_end|>`.)
|
|
2414
|
+
//
|
|
2415
|
+
// Production inference servers are expected to parse this format server-side
|
|
2416
|
+
// and translate it into OpenAI-shaped `choice.delta.tool_calls`. When the
|
|
2417
|
+
// translation breaks (observed against Fireworks' `kimi-k2p6-turbo` router on
|
|
2418
|
+
// 2026-05-24; vLLM had a similar class of leak fixed in
|
|
2419
|
+
// https://github.com/vllm-project/vllm/pull/38579), the raw tokens flow
|
|
2420
|
+
// through `choice.delta.content` instead. pi-ai's `openai-completions`
|
|
2421
|
+
// provider is vendor-neutral and has no Kimi-specific parser, so they land
|
|
2422
|
+
// verbatim in the assistant message's text content with `stopReason: 'stop'`.
|
|
2423
|
+
//
|
|
2424
|
+
// Used as a defense-in-depth check at the `channel_send` / `channel_reply`
|
|
2425
|
+
// tool boundary so a model that somehow passes raw delimiter text as the
|
|
2426
|
+
// message body is denied. NOT used directly by the recovery path in
|
|
2427
|
+
// `validateChannelTurn` — see `isLikelyKimiChannelToolLeak` below.
|
|
2428
|
+
const KIMI_TOOL_DELIMITER_RE = /<\|tool_calls_section_(?:begin|end)\|>|<\|tool_call_(?:begin|end|argument_begin)\|>/
|
|
2429
|
+
|
|
2430
|
+
export function containsKimiToolDelimiter(text: string): boolean {
|
|
2431
|
+
return KIMI_TOOL_DELIMITER_RE.test(text)
|
|
2432
|
+
}
|
|
2433
|
+
|
|
2434
|
+
// Narrower predicate used by `validateChannelTurn` to decide whether to
|
|
2435
|
+
// suppress recovery of assistant text. Requires BOTH:
|
|
2436
|
+
// (1) at least one Kimi tool-call delimiter token, AND
|
|
2437
|
+
// (2) a recognizable channel-tool-call identifier (`channel_reply:N` or
|
|
2438
|
+
// `channel_send:N`, with or without the `functions.` prefix).
|
|
2439
|
+
//
|
|
2440
|
+
// The two-signal rule narrows the false-positive surface to "the model was
|
|
2441
|
+
// trying to call a channel tool and the upstream parser failed". Bare-text
|
|
2442
|
+
// discussion of the Kimi protocol — e.g. the agent answering "explain Kimi's
|
|
2443
|
+
// tool-call format" with documentation-style prose containing `<|tool_call_begin|>`
|
|
2444
|
+
// — does NOT trigger suppression and reaches the user normally. The leak shape
|
|
2445
|
+
// observed in production (`channel_reply:0<|tool_call_argument_begin|>{...}<|tool_calls_section_end|>`)
|
|
2446
|
+
// satisfies both conditions trivially.
|
|
2447
|
+
//
|
|
2448
|
+
// The tool-name regex deliberately stays loose on the index suffix
|
|
2449
|
+
// (`channel_reply:0` / `channel_reply:1` / `channel_send:0` / ...): every
|
|
2450
|
+
// observed leak uses the canonical `functions.<name>:<idx>` shape, but partial
|
|
2451
|
+
// parsers may strip the `functions.` prefix before the leak surfaces.
|
|
2452
|
+
const KIMI_CHANNEL_TOOL_ID_RE = /(?:functions\.)?channel_(?:reply|send):\d+/
|
|
2453
|
+
|
|
2454
|
+
export function isLikelyKimiChannelToolLeak(text: string): boolean {
|
|
2455
|
+
if (!containsKimiToolDelimiter(text)) return false
|
|
2456
|
+
return KIMI_CHANNEL_TOOL_ID_RE.test(text)
|
|
2457
|
+
}
|
|
2458
|
+
|
|
2161
2459
|
function describe(err: unknown): string {
|
|
2162
2460
|
return err instanceof Error ? err.message : String(err)
|
|
2163
2461
|
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { parseSubagentCompletedPayload } from '@/agent/subagent-completion-reminder'
|
|
2
|
+
import type { Stream } from '@/stream'
|
|
3
|
+
|
|
4
|
+
import type { ChannelRouter } from './router'
|
|
5
|
+
|
|
6
|
+
export type SubagentCompletionBridgeLogger = {
|
|
7
|
+
info: (msg: string) => void
|
|
8
|
+
warn: (msg: string) => void
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type SubagentCompletionBridgeOptions = {
|
|
12
|
+
stream: Stream
|
|
13
|
+
router: Pick<ChannelRouter, 'injectSubagentCompletionReminder'>
|
|
14
|
+
logger?: SubagentCompletionBridgeLogger
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type SubagentCompletionBridge = {
|
|
18
|
+
stop: () => void
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const consoleLogger: SubagentCompletionBridgeLogger = {
|
|
22
|
+
info: (msg) => console.log(msg),
|
|
23
|
+
warn: (msg) => console.warn(msg),
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Bridges `subagent.completed` broadcasts on the in-process Stream into a
|
|
27
|
+
// channel router call so the channel session that spawned the subagent
|
|
28
|
+
// gets woken up with a `<system-reminder>` when the subagent finishes.
|
|
29
|
+
//
|
|
30
|
+
// Two-bridges-for-two-surfaces design (matches the TUI side at
|
|
31
|
+
// src/server/index.ts `routeSubagentCompletionReminder`):
|
|
32
|
+
//
|
|
33
|
+
// - TUI sessions: the WS server subscribes to broadcasts on the same
|
|
34
|
+
// stream and re-publishes the reminder as `target: { kind: 'session' }`
|
|
35
|
+
// so the per-session drain loop in the server picks it up. Lookup is
|
|
36
|
+
// by sessionId (which is `state.sessionFileId`).
|
|
37
|
+
//
|
|
38
|
+
// - Channel sessions: this bridge subscribes and calls
|
|
39
|
+
// `router.injectSubagentCompletionReminder` because the channel router
|
|
40
|
+
// owns its own per-key drain loop and doesn't use the stream's
|
|
41
|
+
// session-keyed target.
|
|
42
|
+
//
|
|
43
|
+
// `parentSessionId` matching is the same on both sides: when a channel
|
|
44
|
+
// session spawns a subagent via `spawn_subagent`, the tool captures
|
|
45
|
+
// `sessionManager.getSessionId()` and publishes it as the broadcast's
|
|
46
|
+
// `parentSessionId`. That id is exactly what the router stores on each
|
|
47
|
+
// `LiveSession`, so the lookup is O(N) over live sessions with N small
|
|
48
|
+
// (one per active conversation).
|
|
49
|
+
//
|
|
50
|
+
// On `no-live-session`, we silently drop. Three observable paths reach
|
|
51
|
+
// this branch in production:
|
|
52
|
+
//
|
|
53
|
+
// - The parent session was GC'd by the idle-eviction tick
|
|
54
|
+
// (SESSION_IDLE_MS) while the subagent was running.
|
|
55
|
+
// - The parent session rolled over (SESSION_FRESHNESS_TTL_MS) when a
|
|
56
|
+
// new inbound arrived during a long-running subagent — the channel
|
|
57
|
+
// conversation continues on the new sessionId, but the broadcast
|
|
58
|
+
// still carries the old one.
|
|
59
|
+
// - The parent was a TUI session (the TUI bridge in
|
|
60
|
+
// src/server/index.ts handles it).
|
|
61
|
+
//
|
|
62
|
+
// The right fix for the first two paths is for the broadcast to carry
|
|
63
|
+
// the channel-key coordinate `{ adapter, workspace, chat, thread }` so
|
|
64
|
+
// the bridge can fall back to "any live session for the same channel
|
|
65
|
+
// key" when the exact sessionId no longer matches. That requires
|
|
66
|
+
// extending the broadcast payload (consumed by TUI and channel paths)
|
|
67
|
+
// and gating spawn_subagent to capture the origin coordinates — both
|
|
68
|
+
// non-trivial. Deferred until we see this drop pattern in production
|
|
69
|
+
// logs; the info log line below makes the case diagnosable from logs
|
|
70
|
+
// alone.
|
|
71
|
+
export function createSubagentCompletionBridge(options: SubagentCompletionBridgeOptions): SubagentCompletionBridge {
|
|
72
|
+
const logger = options.logger ?? consoleLogger
|
|
73
|
+
const unsubscribe = options.stream.subscribe({ target: { kind: 'broadcast' } }, (msg) => {
|
|
74
|
+
const parsed = parseSubagentCompletedPayload(msg.payload)
|
|
75
|
+
if (parsed === null) return
|
|
76
|
+
const result = options.router.injectSubagentCompletionReminder(parsed)
|
|
77
|
+
if (result.kind === 'no-live-session') {
|
|
78
|
+
logger.info(
|
|
79
|
+
`[channels] subagent-completion reminder dropped: no live session for parentSessionId=${parsed.parentSessionId} task=${parsed.taskId}`,
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
})
|
|
83
|
+
return { stop: unsubscribe }
|
|
84
|
+
}
|