typeclaw 0.9.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/package.json +1 -1
- package/src/agent/subagents.ts +72 -13
- 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/memory/README.md +11 -11
- package/src/bundled-plugins/memory/index.ts +22 -1
- package/src/bundled-plugins/memory/memory-retrieval.ts +6 -0
- package/src/bundled-plugins/memory/migration.ts +73 -2
- package/src/channels/adapters/kakaotalk-classify.ts +4 -1
- package/src/channels/adapters/kakaotalk.ts +1 -1
- package/src/channels/router.ts +119 -5
- package/src/inspect/replay.ts +30 -0
- package/src/run/index.ts +27 -11
- package/typeclaw.schema.json +8 -1
package/package.json
CHANGED
package/src/agent/subagents.ts
CHANGED
|
@@ -48,6 +48,20 @@ export type SubagentShared<P = unknown> = {
|
|
|
48
48
|
toolResultBudget?: ToolResultBudget
|
|
49
49
|
visibility?: 'public' | 'internal'
|
|
50
50
|
requiresSpecificPermission?: boolean
|
|
51
|
+
// Wall-clock ceiling on a single spawn, enforced at the orchestration
|
|
52
|
+
// layer (both `dispatchSpawnSubagent` and the stream-driven
|
|
53
|
+
// `SubagentConsumer`). When exceeded, the orchestrator's `await` settles
|
|
54
|
+
// with a timeout error and releases the coalescing key for `inFlightKey`,
|
|
55
|
+
// so the next spawn of the same (name, inFlightKey) can proceed instead
|
|
56
|
+
// of being skip-coalesced. The underlying `invokeSubagent` call may keep
|
|
57
|
+
// running — pi-coding-agent's `session.prompt` does not accept an
|
|
58
|
+
// AbortSignal today, so a half-open LLM stream stays alive until the OS
|
|
59
|
+
// reaps it. The trade-off is honest: cancellation is upstream's job;
|
|
60
|
+
// releasing the coalescing key is ours, and that is what unblocks the
|
|
61
|
+
// user-visible "every subsequent turn skipped while the first spawn
|
|
62
|
+
// hangs" symptom. Omit for no ceiling (legacy behavior; the spawn waits
|
|
63
|
+
// as long as the provider takes).
|
|
64
|
+
timeoutMs?: number
|
|
51
65
|
}
|
|
52
66
|
|
|
53
67
|
export type Subagent<P = unknown> = SubagentShared<P> & {
|
|
@@ -248,6 +262,42 @@ export async function invokeSubagent(name: string, options: InvokeSubagentOption
|
|
|
248
262
|
}
|
|
249
263
|
}
|
|
250
264
|
|
|
265
|
+
export class SubagentTimeoutError extends Error {
|
|
266
|
+
override readonly name = 'SubagentTimeoutError'
|
|
267
|
+
constructor(
|
|
268
|
+
readonly subagentName: string,
|
|
269
|
+
readonly coalesceKey: string,
|
|
270
|
+
readonly timeoutMs: number,
|
|
271
|
+
) {
|
|
272
|
+
super(`subagent ${subagentName} (key=${coalesceKey}) spawn timed out after ${timeoutMs}ms`)
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export function isSubagentTimeoutError(err: unknown): err is SubagentTimeoutError {
|
|
277
|
+
return err instanceof SubagentTimeoutError
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export async function awaitWithSubagentTimeout(
|
|
281
|
+
work: Promise<void>,
|
|
282
|
+
subagentName: string,
|
|
283
|
+
coalesceKey: string,
|
|
284
|
+
timeoutMs: number | undefined,
|
|
285
|
+
): Promise<void> {
|
|
286
|
+
if (timeoutMs === undefined) {
|
|
287
|
+
await work
|
|
288
|
+
return
|
|
289
|
+
}
|
|
290
|
+
let timer: ReturnType<typeof setTimeout> | null = null
|
|
291
|
+
const timeout = new Promise<never>((_, reject) => {
|
|
292
|
+
timer = setTimeout(() => reject(new SubagentTimeoutError(subagentName, coalesceKey, timeoutMs)), timeoutMs)
|
|
293
|
+
})
|
|
294
|
+
try {
|
|
295
|
+
await Promise.race([work, timeout])
|
|
296
|
+
} finally {
|
|
297
|
+
if (timer !== null) clearTimeout(timer)
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
251
301
|
export type SubagentHandle = {
|
|
252
302
|
taskId: string
|
|
253
303
|
sessionId: string | undefined
|
|
@@ -447,20 +497,29 @@ export function createSubagentConsumer({
|
|
|
447
497
|
inFlight.add(key)
|
|
448
498
|
try {
|
|
449
499
|
const spawnedByOrigin = parseSpawnedByOriginJson(target.spawnedByOriginJson, logger, name)
|
|
450
|
-
await
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
500
|
+
await awaitWithSubagentTimeout(
|
|
501
|
+
invokeSubagent(name, {
|
|
502
|
+
registry,
|
|
503
|
+
...(createSessionForSubagent !== undefined ? { createSessionForSubagent } : {}),
|
|
504
|
+
agentDir,
|
|
505
|
+
userPrompt: '',
|
|
506
|
+
payload: msg.payload,
|
|
507
|
+
onProviderError: (message) => logger.error(`[subagent] ${key}: LLM call failed: ${message}`),
|
|
508
|
+
...(target.parentSessionId !== undefined ? { parentSessionId: target.parentSessionId } : {}),
|
|
509
|
+
...(target.spawnedByRole !== undefined ? { spawnedByRole: target.spawnedByRole } : {}),
|
|
510
|
+
...(spawnedByOrigin !== undefined ? { spawnedByOrigin } : {}),
|
|
511
|
+
}),
|
|
512
|
+
name,
|
|
513
|
+
key,
|
|
514
|
+
registry[name]?.timeoutMs,
|
|
515
|
+
)
|
|
461
516
|
} catch (err) {
|
|
462
|
-
|
|
463
|
-
|
|
517
|
+
if (isSubagentTimeoutError(err)) {
|
|
518
|
+
logger.warn(`[subagent] ${key} timed out after ${err.timeoutMs}ms; releasing coalesce key`)
|
|
519
|
+
} else {
|
|
520
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
521
|
+
logger.error(`[subagent] ${key} failed: ${message}`)
|
|
522
|
+
}
|
|
464
523
|
} finally {
|
|
465
524
|
inFlight.delete(key)
|
|
466
525
|
}
|
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
import { Type } from '@mariozechner/pi-ai'
|
|
2
2
|
import { defineTool } from '@mariozechner/pi-coding-agent'
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
containsKimiToolDelimiter,
|
|
6
|
+
isNoReplySignal,
|
|
7
|
+
isUpstreamEmptyResponseSentinel,
|
|
8
|
+
type ChannelRouter,
|
|
9
|
+
} from '@/channels/router'
|
|
5
10
|
import type { AdapterId } from '@/channels/schema'
|
|
6
11
|
|
|
7
12
|
import { type ChannelToolLogger, consoleChannelLogger, formatChannelToolFailure } from './channel-log'
|
|
13
|
+
import { fenceRuntimeNotice } from './runtime-notice'
|
|
8
14
|
|
|
9
15
|
export type ChannelReplyOrigin = {
|
|
10
16
|
adapter: AdapterId
|
|
@@ -98,6 +104,15 @@ export function createChannelReplyTool({
|
|
|
98
104
|
}
|
|
99
105
|
}
|
|
100
106
|
|
|
107
|
+
const kimiLeakError = kimiToolCallLeakError(text)
|
|
108
|
+
if (kimiLeakError) {
|
|
109
|
+
logger.warn(formatChannelToolFailure('channel_reply', kimiLeakError))
|
|
110
|
+
return {
|
|
111
|
+
content: [{ type: 'text' as const, text: `channel_reply denied: ${kimiLeakError}` }],
|
|
112
|
+
details: { ok: false, error: kimiLeakError },
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
101
116
|
const result = await router.send({
|
|
102
117
|
adapter: origin.adapter,
|
|
103
118
|
workspace: origin.workspace,
|
|
@@ -148,14 +163,24 @@ export function createChannelReplyTool({
|
|
|
148
163
|
}),
|
|
149
164
|
)
|
|
150
165
|
: ''
|
|
166
|
+
const body = hint ? `${baseText}${hint}` : baseText
|
|
151
167
|
return {
|
|
152
|
-
content: [{ type: 'text' as const, text:
|
|
168
|
+
content: [{ type: 'text' as const, text: `${TOOL_RESULT_PREFIX}${body}` }],
|
|
153
169
|
details,
|
|
154
170
|
}
|
|
155
171
|
},
|
|
156
172
|
})
|
|
157
173
|
}
|
|
158
174
|
|
|
175
|
+
// Tool results reach the model as USER-role messages (OpenAI / Anthropic
|
|
176
|
+
// tool-API contract — the engine cannot tag them as system). Without this
|
|
177
|
+
// marker a persona-rich model reads its own echo as a fresh user inbound
|
|
178
|
+
// and replies to itself. Observed in production: Kimi K2 on KakaoTalk
|
|
179
|
+
// re-invoked after a successful send saw only the echo as new context
|
|
180
|
+
// and hallucinated a goodbye trigger from it. Mirrored verbatim in
|
|
181
|
+
// channel-send.ts so both tools share one greppable marker.
|
|
182
|
+
export const TOOL_RESULT_PREFIX = '[system: tool result, not a user message] '
|
|
183
|
+
|
|
159
184
|
export const ECHO_MAX_CHARS = 500
|
|
160
185
|
|
|
161
186
|
export function renderEcho(text: string): string {
|
|
@@ -211,12 +236,27 @@ function upstreamEmptyResponseSentinelError(text: string | undefined): string {
|
|
|
211
236
|
)
|
|
212
237
|
}
|
|
213
238
|
|
|
239
|
+
function kimiToolCallLeakError(text: string | undefined): string {
|
|
240
|
+
if (text === undefined) return ''
|
|
241
|
+
if (!containsKimiToolDelimiter(text)) return ''
|
|
242
|
+
return (
|
|
243
|
+
'refusing to forward raw provider tool-call control tokens; these are chat-template ' +
|
|
244
|
+
'delimiters that should have been parsed into a real tool call upstream. ' +
|
|
245
|
+
'Re-issue the intended channel reply as plain user-visible text only.'
|
|
246
|
+
)
|
|
247
|
+
}
|
|
248
|
+
|
|
214
249
|
// Mirror of the same hint used by channel_send. Kept identical so the model
|
|
215
|
-
// sees the same yield signal regardless of which tool it picked.
|
|
250
|
+
// sees the same yield signal regardless of which tool it picked. The body
|
|
251
|
+
// is wrapped via `fenceRuntimeNotice` (in `./runtime-notice`) so persona-rich
|
|
252
|
+
// models cannot read the trailing prose as a chat instruction and reply to
|
|
253
|
+
// it in-character. See that helper's comment for the failure mode that
|
|
254
|
+
// motivated the framing.
|
|
216
255
|
function consecutiveSendHint(countAfterSend: number): string {
|
|
217
256
|
if (countAfterSend <= 1) return ''
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
257
|
+
const body =
|
|
258
|
+
countAfterSend === 2
|
|
259
|
+
? 'this is your 2nd consecutive message in this conversation; continue only if the reply genuinely needs splitting.'
|
|
260
|
+
: `${countAfterSend}th consecutive message with no user reply; end your turn now unless the user explicitly asked for a multi-step response.`
|
|
261
|
+
return fenceRuntimeNotice(body)
|
|
222
262
|
}
|
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
import { Type } from '@mariozechner/pi-ai'
|
|
2
2
|
import { defineTool } from '@mariozechner/pi-coding-agent'
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
containsKimiToolDelimiter,
|
|
6
|
+
isNoReplySignal,
|
|
7
|
+
isUpstreamEmptyResponseSentinel,
|
|
8
|
+
type ChannelRouter,
|
|
9
|
+
} from '@/channels/router'
|
|
5
10
|
import { ADAPTER_IDS, type AdapterId } from '@/channels/schema'
|
|
6
11
|
|
|
7
12
|
import { type ChannelToolLogger, consoleChannelLogger, formatChannelToolFailure } from './channel-log'
|
|
8
|
-
import { renderOutboundEcho } from './channel-reply'
|
|
13
|
+
import { renderOutboundEcho, TOOL_RESULT_PREFIX } from './channel-reply'
|
|
14
|
+
import { fenceRuntimeNotice } from './runtime-notice'
|
|
9
15
|
|
|
10
16
|
export type ChannelSendOrigin = {
|
|
11
17
|
adapter: AdapterId
|
|
@@ -121,6 +127,15 @@ export function createChannelSendTool({ router, origin, logger = consoleChannelL
|
|
|
121
127
|
}
|
|
122
128
|
}
|
|
123
129
|
|
|
130
|
+
const kimiLeakError = kimiToolCallLeakError(bodyText)
|
|
131
|
+
if (kimiLeakError) {
|
|
132
|
+
logger.warn(formatChannelToolFailure('channel_send', kimiLeakError))
|
|
133
|
+
return {
|
|
134
|
+
content: [{ type: 'text' as const, text: `channel_send denied: ${kimiLeakError}` }],
|
|
135
|
+
details: { ok: false, error: kimiLeakError },
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
124
139
|
const result = await router.send({
|
|
125
140
|
adapter,
|
|
126
141
|
workspace: params.workspace,
|
|
@@ -163,9 +178,9 @@ export function createChannelSendTool({ router, origin, logger = consoleChannelL
|
|
|
163
178
|
})
|
|
164
179
|
if (threadMismatch) hints.push(threadMismatch)
|
|
165
180
|
}
|
|
166
|
-
const
|
|
181
|
+
const body = hints.length > 0 ? `${baseText}${hints.join('')}` : baseText
|
|
167
182
|
return {
|
|
168
|
-
content: [{ type: 'text' as const, text:
|
|
183
|
+
content: [{ type: 'text' as const, text: `${TOOL_RESULT_PREFIX}${body}` }],
|
|
169
184
|
details,
|
|
170
185
|
}
|
|
171
186
|
},
|
|
@@ -181,6 +196,11 @@ export function createChannelSendTool({ router, origin, logger = consoleChannelL
|
|
|
181
196
|
//
|
|
182
197
|
// Only fires when the origin had a thread to begin with — channel-root
|
|
183
198
|
// sessions can't have a "missing thread" problem.
|
|
199
|
+
//
|
|
200
|
+
// Body is fenced via `fenceRuntimeNotice` for the same reason the
|
|
201
|
+
// consecutive-send hint is — see that helper's comment for the failure
|
|
202
|
+
// mode (Kimi-K2.x reading trailing tool-result prose as a chat instruction
|
|
203
|
+
// and replying to it in-character).
|
|
184
204
|
function threadMismatchHint(
|
|
185
205
|
origin: ChannelSendOrigin | undefined,
|
|
186
206
|
sent: { adapter: AdapterId; workspace: string; chat: string; thread: string | undefined },
|
|
@@ -191,10 +211,10 @@ function threadMismatchHint(
|
|
|
191
211
|
if (origin.adapter !== sent.adapter) return ''
|
|
192
212
|
if (origin.workspace !== sent.workspace) return ''
|
|
193
213
|
if (origin.chat !== sent.chat) return ''
|
|
194
|
-
return (
|
|
214
|
+
return fenceRuntimeNotice(
|
|
195
215
|
`note: this session's origin thread is ${JSON.stringify(origin.thread)} but you posted to channel root. ` +
|
|
196
|
-
|
|
197
|
-
|
|
216
|
+
`if breaking out of the thread was intentional, ignore this; otherwise prefer \`channel_reply\` ` +
|
|
217
|
+
`or pass \`thread: ${JSON.stringify(origin.thread)}\` on your next channel_send.`,
|
|
198
218
|
)
|
|
199
219
|
}
|
|
200
220
|
|
|
@@ -233,16 +253,28 @@ function upstreamEmptyResponseSentinelError(text: string | undefined): string {
|
|
|
233
253
|
)
|
|
234
254
|
}
|
|
235
255
|
|
|
256
|
+
function kimiToolCallLeakError(text: string | undefined): string {
|
|
257
|
+
if (text === undefined) return ''
|
|
258
|
+
if (!containsKimiToolDelimiter(text)) return ''
|
|
259
|
+
return (
|
|
260
|
+
'refusing to forward raw provider tool-call control tokens; these are chat-template ' +
|
|
261
|
+
'delimiters that should have been parsed into a real tool call upstream. ' +
|
|
262
|
+
'Re-issue the intended channel send as plain user-visible text only.'
|
|
263
|
+
)
|
|
264
|
+
}
|
|
265
|
+
|
|
236
266
|
// Returns a behavioral hint to nudge the model toward yielding when it has
|
|
237
267
|
// been the only voice in the conversation for several messages. The router
|
|
238
268
|
// increments its counter AFTER router.send returns, so a count of 1 means
|
|
239
269
|
// "this is the second consecutive bot message in this chat:thread" — which
|
|
240
270
|
// is the first count where a hint is warranted. Empty string at count <= 1
|
|
241
271
|
// preserves the original tool-result text for the common single-reply case.
|
|
272
|
+
// Mirror of channel-reply.ts; body wrapped via `fenceRuntimeNotice`.
|
|
242
273
|
function consecutiveSendHint(countAfterSend: number): string {
|
|
243
274
|
if (countAfterSend <= 1) return ''
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
275
|
+
const body =
|
|
276
|
+
countAfterSend === 2
|
|
277
|
+
? 'this is your 2nd consecutive message in this conversation; continue only if the reply genuinely needs splitting.'
|
|
278
|
+
: `${countAfterSend}th consecutive message with no user reply; end your turn now unless the user explicitly asked for a multi-step response.`
|
|
279
|
+
return fenceRuntimeNotice(body)
|
|
248
280
|
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Wraps a runtime-emitted notice body in canonical SYSTEM MESSAGE framing so
|
|
2
|
+
// persona-rich models cannot read the prose as a chat instruction from a
|
|
3
|
+
// human and respond to it in-character.
|
|
4
|
+
//
|
|
5
|
+
// The failure mode this exists to prevent: tool results reach the model as
|
|
6
|
+
// USER-role messages (provider tool-call contract — engines cannot tag them
|
|
7
|
+
// as system). The `TOOL_RESULT_PREFIX` already marks each result's leading
|
|
8
|
+
// position, but trailing natural-language hints (the consecutive-send nudge
|
|
9
|
+
// is the canonical case) still parse as conversational prose, and Kimi-K2.x
|
|
10
|
+
// has been observed in production responding to those hints in-character —
|
|
11
|
+
// an apology directly addressed at the human ("sorry for talking so much,
|
|
12
|
+
// I'll be quieter next time") when the only stimulus in the prompt was the
|
|
13
|
+
// router's "Nth consecutive message; end your turn now" hint. Four
|
|
14
|
+
// consecutive in-character replies to fenced-prose runtime hints in a
|
|
15
|
+
// single drain iteration is the observed shape.
|
|
16
|
+
//
|
|
17
|
+
// Framing convention is the same shape `composeTurnPrompt` uses for the
|
|
18
|
+
// loop-guard block in `router.ts` — bracketed marker, fence rules, and
|
|
19
|
+
// explicit "Do not acknowledge or reply to this notice" closer. The
|
|
20
|
+
// loop-guard block has been in production against Kimi for months without
|
|
21
|
+
// the misread we observed on the consecutive-send hint, which is why we
|
|
22
|
+
// reuse the exact same shape here.
|
|
23
|
+
//
|
|
24
|
+
// Applied unconditionally (not model-gated): the cost is ~40 tokens per
|
|
25
|
+
// hint emission, paid only on consecutive sends (where the hint is already
|
|
26
|
+
// firing), and the framing is safe for every model — well-behaved models
|
|
27
|
+
// read it and move on. Gating by model family would have required a
|
|
28
|
+
// traits table for one defense and would still need extending the moment
|
|
29
|
+
// a second model family exhibited the same misread, so we accept the
|
|
30
|
+
// universal cost in exchange for never having to remember to add a new
|
|
31
|
+
// family to a list.
|
|
32
|
+
export function fenceRuntimeNotice(body: string): string {
|
|
33
|
+
return (
|
|
34
|
+
'\n\n---\n' +
|
|
35
|
+
'**[SYSTEM MESSAGE — not from a human]**\n\n' +
|
|
36
|
+
body +
|
|
37
|
+
'\n\nThis is an automated signal from the channel router, not a message ' +
|
|
38
|
+
'from anyone in the chat. **Do not acknowledge or reply to this notice.**\n' +
|
|
39
|
+
'---'
|
|
40
|
+
)
|
|
41
|
+
}
|
|
@@ -28,17 +28,17 @@ All fields are **restart-required** — the plugin reads them once at boot.
|
|
|
28
28
|
|
|
29
29
|
## What it contributes
|
|
30
30
|
|
|
31
|
-
| Kind | Name | Notes
|
|
32
|
-
| -------- | -------------------------- |
|
|
33
|
-
| Subagent | `memory-logger` | Reads a parent transcript past a watermark and appends fragments to `memory/streams/<today>.jsonl`. Coalesced per `agentDir`.
|
|
34
|
-
| Subagent | `dreaming` | Reads shards under `memory/topics/` plus undreamed daily-stream events and rebalances the topic shards. Coalesced per `agentDir`. Citation-superset invariant enforced on every run.
|
|
35
|
-
| Subagent | `memory-retrieval` | On `session.turn.start` when injection plan is `index` mode, reads the user's actual prompt for this turn + shard listing, writes a focused summary to `memory/.retrieval-cache/<sessionId>.md`. Coalesced per `parentSessionId`.
|
|
36
|
-
| Tool | `memory_search` | Main-agent tool. Substring/regex search across BOTH topic shards (slugs, frontmatter, bodies) and undreamed daily-stream events (fragment topic/body, legacy prose). Results are discriminated by `source: "topic" \| "stream"`; topics come first, then streams newest-first.
|
|
37
|
-
| Tool | `delete_topic_shard` | Subagent-only (dreaming). Deletes a topic shard at `memory/topics/<slug>.md`. Path-guarded.
|
|
38
|
-
| Cron | `__plugin_memory_dreaming` | `kind: 'prompt'`, `subagent: 'dreaming'`, scheduled per `memory.dreaming.schedule`.
|
|
39
|
-
| Hook | `session.idle` | Per-session debouncer with size-based ceiling. Spawns `memory-logger` on idle or buffer-trip.
|
|
40
|
-
| Hook | `session.end` | Spawns `memory-logger` immediately; also unlinks the retrieval-cache file for this session.
|
|
41
|
-
| Hook | `session.turn.start` | When `buildInjectionPlan` returns `mode: 'index'` and origin is not a subagent, spawns `memory-retrieval` (detached) with the turn's `userPrompt` so the cache reflects the user's current question, not the assembling system prompt. Fire-and-forget; failures route through the plugin logger.
|
|
31
|
+
| Kind | Name | Notes |
|
|
32
|
+
| -------- | -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
33
|
+
| Subagent | `memory-logger` | Reads a parent transcript past a watermark and appends fragments to `memory/streams/<today>.jsonl`. Coalesced per `agentDir`. |
|
|
34
|
+
| Subagent | `dreaming` | Reads shards under `memory/topics/` plus undreamed daily-stream events and rebalances the topic shards. Coalesced per `agentDir`. Citation-superset invariant enforced on every run. |
|
|
35
|
+
| Subagent | `memory-retrieval` | On `session.turn.start` when injection plan is `index` mode, reads the user's actual prompt for this turn + shard listing, writes a focused summary to `memory/.retrieval-cache/<sessionId>.md`. Coalesced per `parentSessionId`. Declares `profile: 'fast'` (retrieval is "≤3 keyword searches + 1 write", no reasoning required) and `timeoutMs: 30_000` so a wedged provider call releases the coalescing key instead of poisoning the cache for every subsequent turn. |
|
|
36
|
+
| Tool | `memory_search` | Main-agent tool. Substring/regex search across BOTH topic shards (slugs, frontmatter, bodies) and undreamed daily-stream events (fragment topic/body, legacy prose). Results are discriminated by `source: "topic" \| "stream"`; topics come first, then streams newest-first. |
|
|
37
|
+
| Tool | `delete_topic_shard` | Subagent-only (dreaming). Deletes a topic shard at `memory/topics/<slug>.md`. Path-guarded. |
|
|
38
|
+
| Cron | `__plugin_memory_dreaming` | `kind: 'prompt'`, `subagent: 'dreaming'`, scheduled per `memory.dreaming.schedule`. |
|
|
39
|
+
| Hook | `session.idle` | Per-session debouncer with size-based ceiling. Spawns `memory-logger` on idle or buffer-trip. |
|
|
40
|
+
| Hook | `session.end` | Spawns `memory-logger` immediately; also unlinks the retrieval-cache file for this session. |
|
|
41
|
+
| Hook | `session.turn.start` | When `buildInjectionPlan` returns `mode: 'index'` and origin is not a subagent, spawns `memory-retrieval` (detached) with the turn's `userPrompt` so the cache reflects the user's current question, not the assembling system prompt. Fire-and-forget; failures route through the plugin logger. |
|
|
42
42
|
|
|
43
43
|
## Memory injection (two-tier, topic shards only)
|
|
44
44
|
|
|
@@ -27,6 +27,17 @@ const MIN_BUFFER_BYTES = 10_000
|
|
|
27
27
|
// sporadic agents entirely. Operators can override via `memory.dreaming.schedule`.
|
|
28
28
|
const DEFAULT_DREAMING_SCHEDULE = '*/30 * * * *'
|
|
29
29
|
|
|
30
|
+
// memory-retrieval's ceiling, enforced by the orchestration layer (see
|
|
31
|
+
// `awaitWithSubagentTimeout` in @/agent/subagents). 30s is sized for the
|
|
32
|
+
// declared workload — up to 3 `memory_search` calls + 1 `write` against a
|
|
33
|
+
// `fast`-profile model. The 5+ minute outliers observed in the wild
|
|
34
|
+
// (reasoning-model cold-start on the default profile) require either a
|
|
35
|
+
// genuinely wedged provider, a misconfigured profile that routes retrieval
|
|
36
|
+
// to a reasoning model anyway, or both. In all three cases, releasing the
|
|
37
|
+
// coalescing key after 30s lets the next channel turn spawn a fresh
|
|
38
|
+
// retrieval instead of staying skip-coalesced behind the stuck one.
|
|
39
|
+
const RETRIEVAL_SPAWN_TIMEOUT_MS = 30_000
|
|
40
|
+
|
|
30
41
|
// Hard ceiling on a single memory-logger spawn. The chain serializes spawns
|
|
31
42
|
// per agent, so a non-settling spawn would otherwise wedge every subsequent
|
|
32
43
|
// fire — including the session.end hook path that gates cron consumer's
|
|
@@ -86,6 +97,11 @@ const memoryConfigSchema = z
|
|
|
86
97
|
// the timeout in milliseconds instead of the production 50s. Kept
|
|
87
98
|
// undocumented for users.
|
|
88
99
|
spawnTimeoutMs: z.number().int().min(1).default(SPAWN_TIMEOUT_MS),
|
|
100
|
+
// Test seam: per-spawn ceiling for memory-retrieval. Same rationale as
|
|
101
|
+
// `spawnTimeoutMs` — operators have no reason to tune this; it exists
|
|
102
|
+
// so the wedge-recovery test for memory-retrieval can fire the timeout
|
|
103
|
+
// in milliseconds instead of the production 30s.
|
|
104
|
+
retrievalSpawnTimeoutMs: z.number().int().min(1).default(RETRIEVAL_SPAWN_TIMEOUT_MS),
|
|
89
105
|
dreaming: dreamingConfigSchema.optional(),
|
|
90
106
|
})
|
|
91
107
|
.default({
|
|
@@ -93,6 +109,7 @@ const memoryConfigSchema = z
|
|
|
93
109
|
bufferBytes: DEFAULT_BUFFER_BYTES,
|
|
94
110
|
injectionBudgetBytes: DEFAULT_INJECTION_BUDGET_BYTES,
|
|
95
111
|
spawnTimeoutMs: SPAWN_TIMEOUT_MS,
|
|
112
|
+
retrievalSpawnTimeoutMs: RETRIEVAL_SPAWN_TIMEOUT_MS,
|
|
96
113
|
})
|
|
97
114
|
|
|
98
115
|
export default definePlugin({
|
|
@@ -101,6 +118,7 @@ export default definePlugin({
|
|
|
101
118
|
const idleMs = ctx.config.idleMs
|
|
102
119
|
const bufferBytes = ctx.config.bufferBytes
|
|
103
120
|
const spawnTimeoutMs = ctx.config.spawnTimeoutMs
|
|
121
|
+
const retrievalSpawnTimeoutMs = ctx.config.retrievalSpawnTimeoutMs
|
|
104
122
|
const dreamingSchedule = ctx.config.dreaming?.schedule ?? DEFAULT_DREAMING_SCHEDULE
|
|
105
123
|
|
|
106
124
|
const migrationResult = await runMigration({
|
|
@@ -224,7 +242,10 @@ export default definePlugin({
|
|
|
224
242
|
return {
|
|
225
243
|
subagents: {
|
|
226
244
|
'memory-logger': createMemoryLoggerSubagent({ logger: subagentLogger }),
|
|
227
|
-
'memory-retrieval': createMemoryRetrievalSubagent({
|
|
245
|
+
'memory-retrieval': createMemoryRetrievalSubagent({
|
|
246
|
+
logger: subagentLogger,
|
|
247
|
+
timeoutMs: retrievalSpawnTimeoutMs,
|
|
248
|
+
}),
|
|
228
249
|
dreaming: createDreamingSubagent({ logger: subagentLogger }),
|
|
229
250
|
},
|
|
230
251
|
tools: {
|
|
@@ -26,6 +26,7 @@ export type MemoryRetrievalLogger = {
|
|
|
26
26
|
|
|
27
27
|
export type CreateMemoryRetrievalSubagentOptions = {
|
|
28
28
|
logger?: MemoryRetrievalLogger
|
|
29
|
+
timeoutMs?: number
|
|
29
30
|
}
|
|
30
31
|
|
|
31
32
|
export const MEMORY_RETRIEVAL_SYSTEM_PROMPT = `You are the memory-retrieval subagent. Read the user's most recent prompt and decide what's relevant from BOTH topic shards in \`memory/topics/\` (consolidated long-term memory) AND undreamed daily-stream events under \`memory/streams/\` (recent fragments not yet folded into shards). Use \`memory_search\` to query both surfaces; use \`read\`/\`ls\` to pull full shard bodies when needed. Synthesize a focused ≤8 KB summary of the relevant memory. Save by \`write\`ing it to the exact path provided in your payload as \`cacheFilePath\`. Be ruthlessly concise. Do NOT write anywhere else. Do NOT delete files.
|
|
@@ -56,10 +57,15 @@ export function createMemoryRetrievalSubagent(
|
|
|
56
57
|
const logger = options.logger ?? consoleLogger
|
|
57
58
|
return {
|
|
58
59
|
systemPrompt: MEMORY_RETRIEVAL_SYSTEM_PROMPT,
|
|
60
|
+
// Retrieval is "4 keyword searches + 1 write" — no reasoning required.
|
|
61
|
+
// `fast` falls back to `default` (with a one-time warning) when the
|
|
62
|
+
// operator hasn't configured it, so this is safe by construction.
|
|
63
|
+
profile: 'fast',
|
|
59
64
|
tools: [readTool, writeTool, lsTool],
|
|
60
65
|
customTools: [memorySearchTool],
|
|
61
66
|
payloadSchema: memoryRetrievalPayloadSchema,
|
|
62
67
|
inFlightKey: (payload) => payload.parentSessionId,
|
|
68
|
+
...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
|
|
63
69
|
// 256 KB read + memory_search budget. Sized for one retrieval pass:
|
|
64
70
|
// ~16 KB of memory_search hits (3 queries × ~5 KB excerpts) plus a few
|
|
65
71
|
// shard reads (~5 KB each). A smaller budget would systematically
|
|
@@ -77,7 +77,7 @@ export async function runShardingMigration(options: RunShardingMigrationOptions)
|
|
|
77
77
|
...extra,
|
|
78
78
|
})
|
|
79
79
|
|
|
80
|
-
await recoverShardingOrphans(options.agentDir, options.logger)
|
|
80
|
+
await recoverShardingOrphans(options.agentDir, options.logger, options.git)
|
|
81
81
|
|
|
82
82
|
if (existsSync(topicsDir(options.agentDir)) || !existsSync(rootMemoryPath(options.agentDir))) {
|
|
83
83
|
return empty()
|
|
@@ -241,7 +241,11 @@ async function recoverShardingMigration(agentDir: string, logger: MigrationLogge
|
|
|
241
241
|
)
|
|
242
242
|
}
|
|
243
243
|
|
|
244
|
-
async function recoverShardingOrphans(
|
|
244
|
+
async function recoverShardingOrphans(
|
|
245
|
+
agentDir: string,
|
|
246
|
+
logger: MigrationLogger,
|
|
247
|
+
git: MigrationGit | undefined,
|
|
248
|
+
): Promise<void> {
|
|
245
249
|
if (!existsSync(topicsDir(agentDir))) return
|
|
246
250
|
|
|
247
251
|
let cleaned = false
|
|
@@ -260,6 +264,11 @@ async function recoverShardingOrphans(agentDir: string, logger: MigrationLogger)
|
|
|
260
264
|
}
|
|
261
265
|
|
|
262
266
|
if (cleaned) logger.info('[memory:migration] cleaned orphaned pre-shard memory files')
|
|
267
|
+
|
|
268
|
+
// Always called, even when nothing was cleaned this boot: pre-#315 migrations
|
|
269
|
+
// and earlier runs of this function unlinked without committing, leaving
|
|
270
|
+
// staged deletions that survive across reboots until cleared explicitly.
|
|
271
|
+
await commitPendingLegacyDeletions(agentDir, logger, git)
|
|
263
272
|
}
|
|
264
273
|
|
|
265
274
|
async function collectFlatJsonlDates(memoryDir: string): Promise<string[]> {
|
|
@@ -540,6 +549,68 @@ async function commitShardingMigration(
|
|
|
540
549
|
}
|
|
541
550
|
}
|
|
542
551
|
|
|
552
|
+
async function commitPendingLegacyDeletions(
|
|
553
|
+
agentDir: string,
|
|
554
|
+
logger: MigrationLogger,
|
|
555
|
+
git: MigrationGit | undefined,
|
|
556
|
+
): Promise<void> {
|
|
557
|
+
const spawn = git?.spawn ?? spawnGit
|
|
558
|
+
const inside = await spawn(['rev-parse', '--is-inside-work-tree'], { cwd: agentDir })
|
|
559
|
+
if (inside.exitCode !== 0) return
|
|
560
|
+
|
|
561
|
+
const pending = await collectLegacyDeletions(agentDir, spawn)
|
|
562
|
+
if (pending.all.length === 0) return
|
|
563
|
+
|
|
564
|
+
// `git add -u` errors with "pathspec did not match" on paths whose deletion
|
|
565
|
+
// is already in the index, so stage only the working-tree-only deletions.
|
|
566
|
+
// The already-staged set is picked up by the commit directly.
|
|
567
|
+
if (pending.workingTreeOnly.length > 0) {
|
|
568
|
+
const addDeletions = await spawn(['add', '-u', '--', ...pending.workingTreeOnly], { cwd: agentDir })
|
|
569
|
+
if (addDeletions.exitCode !== 0) {
|
|
570
|
+
logger.warn(`[memory:migration] git add failed: ${addDeletions.stderr || addDeletions.stdout}`.trim())
|
|
571
|
+
return
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
const commit = await spawn(
|
|
576
|
+
[
|
|
577
|
+
'commit',
|
|
578
|
+
'-m',
|
|
579
|
+
`memory: clean up ${pending.all.length} pre-shard file(s) orphaned by earlier migration`,
|
|
580
|
+
'--no-edit',
|
|
581
|
+
],
|
|
582
|
+
{ cwd: agentDir },
|
|
583
|
+
)
|
|
584
|
+
if (commit.exitCode !== 0) {
|
|
585
|
+
logger.warn(`[memory:migration] git commit failed: ${commit.stderr || commit.stdout}`.trim())
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
async function collectLegacyDeletions(
|
|
590
|
+
agentDir: string,
|
|
591
|
+
spawn: NonNullable<MigrationGit['spawn']>,
|
|
592
|
+
): Promise<{ all: string[]; workingTreeOnly: string[] }> {
|
|
593
|
+
const isLegacy = (line: string): boolean => line === 'MEMORY.md' || /^memory\/\d{4}-\d{2}-\d{2}\.jsonl$/.test(line)
|
|
594
|
+
const parse = (out: string): string[] =>
|
|
595
|
+
out
|
|
596
|
+
.split('\n')
|
|
597
|
+
.map((line) => line.trim())
|
|
598
|
+
.filter(isLegacy)
|
|
599
|
+
|
|
600
|
+
const allDiff = await spawn(['diff', 'HEAD', '--name-only', '--diff-filter=D', '--', 'memory/', 'MEMORY.md'], {
|
|
601
|
+
cwd: agentDir,
|
|
602
|
+
})
|
|
603
|
+
if (allDiff.exitCode !== 0) return { all: [], workingTreeOnly: [] }
|
|
604
|
+
const all = parse(allDiff.stdout)
|
|
605
|
+
if (all.length === 0) return { all: [], workingTreeOnly: [] }
|
|
606
|
+
|
|
607
|
+
const wtDiff = await spawn(['diff', '--name-only', '--diff-filter=D', '--', 'memory/', 'MEMORY.md'], {
|
|
608
|
+
cwd: agentDir,
|
|
609
|
+
})
|
|
610
|
+
const workingTreeOnly = wtDiff.exitCode === 0 ? parse(wtDiff.stdout) : []
|
|
611
|
+
return { all, workingTreeOnly }
|
|
612
|
+
}
|
|
613
|
+
|
|
543
614
|
async function spawnGit(
|
|
544
615
|
args: string[],
|
|
545
616
|
options: { cwd: string },
|
|
@@ -67,7 +67,10 @@ export function classifyInbound(
|
|
|
67
67
|
mentionsOthers: false,
|
|
68
68
|
replyToOtherMessageId: null,
|
|
69
69
|
isDm: chatInfo.isDm,
|
|
70
|
-
|
|
70
|
+
// SDK delivers `sent_at` in Unix seconds (LOCO `sendAt`); contract
|
|
71
|
+
// wants ms (see `src/channels/types.ts`). Without `* 1000`, ms-based
|
|
72
|
+
// renderers (inspect -f, etc.) produce 1970-01-21-shaped dates.
|
|
73
|
+
ts: event.sent_at * 1000,
|
|
71
74
|
},
|
|
72
75
|
}
|
|
73
76
|
}
|
package/src/channels/router.ts
CHANGED
|
@@ -1742,8 +1742,9 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1742
1742
|
const assistantText = latestAssistantText(live.session)
|
|
1743
1743
|
if (assistantText === null) return
|
|
1744
1744
|
|
|
1745
|
-
if (
|
|
1746
|
-
|
|
1745
|
+
if (endsWithNoReplySignal(assistantText)) {
|
|
1746
|
+
const leakedReasoning = !isNoReplySignal(assistantText)
|
|
1747
|
+
logger.info(`[channels] ${live.keyId} no_reply${leakedReasoning ? ' (with_leaked_reasoning)' : ''}`)
|
|
1747
1748
|
return
|
|
1748
1749
|
}
|
|
1749
1750
|
|
|
@@ -1754,6 +1755,11 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1754
1755
|
return
|
|
1755
1756
|
}
|
|
1756
1757
|
|
|
1758
|
+
if (isLikelyKimiChannelToolLeak(assistantText)) {
|
|
1759
|
+
logger.warn(`[channels] ${live.keyId}: suppressed kimi_tool_call_leak text_len=${assistantText.length}`)
|
|
1760
|
+
return
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1757
1763
|
logger.warn(
|
|
1758
1764
|
`[channels] ${live.keyId}: recovering assistant_text_without_channel_tool text_len=${assistantText.length}`,
|
|
1759
1765
|
)
|
|
@@ -2114,10 +2120,23 @@ function composeTurnPrompt(
|
|
|
2114
2120
|
parts.push(formatAuthorLine(o.ts, o.authorId, o.authorName, o.authorIsBot, o.text))
|
|
2115
2121
|
}
|
|
2116
2122
|
parts.push('')
|
|
2117
|
-
parts.push(batch.length === 1 ? '## Current message (addressed to you)' : '## Current messages (addressed to you)')
|
|
2118
2123
|
}
|
|
2119
|
-
|
|
2120
|
-
|
|
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
|
+
}
|
|
2121
2140
|
}
|
|
2122
2141
|
return parts.join('\n')
|
|
2123
2142
|
}
|
|
@@ -2317,6 +2336,45 @@ export function isNoReplySignal(text: string): boolean {
|
|
|
2317
2336
|
return false
|
|
2318
2337
|
}
|
|
2319
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
|
+
|
|
2320
2378
|
// Detects the upstream "empty response" debug sentinel: when the LLM ends a
|
|
2321
2379
|
// turn with only a `thinking` block, some provider SDK paths (observed
|
|
2322
2380
|
// against claude-opus-4-5 via pi-ai) fabricate a single text block whose
|
|
@@ -2342,6 +2400,62 @@ export function isUpstreamEmptyResponseSentinel(text: string): boolean {
|
|
|
2342
2400
|
return trimmed.includes("'stop_reason'")
|
|
2343
2401
|
}
|
|
2344
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
|
+
|
|
2345
2459
|
function describe(err: unknown): string {
|
|
2346
2460
|
return err instanceof Error ? err.message : String(err)
|
|
2347
2461
|
}
|
package/src/inspect/replay.ts
CHANGED
|
@@ -76,6 +76,36 @@ function* eventsFromEntry(
|
|
|
76
76
|
yield* assistantEvents(message as AssistantMessage, ts, pending)
|
|
77
77
|
return
|
|
78
78
|
}
|
|
79
|
+
if (role === 'toolResult') {
|
|
80
|
+
const ev = toolResultMessageEvent(message, ts, pending)
|
|
81
|
+
if (ev !== null) yield ev
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function toolResultMessageEvent(
|
|
87
|
+
message: { role: string; [k: string]: unknown },
|
|
88
|
+
ts: number,
|
|
89
|
+
pending: Map<string, { name: string; startTs: number }>,
|
|
90
|
+
): InspectEvent | null {
|
|
91
|
+
const toolCallId = typeof message.toolCallId === 'string' ? message.toolCallId : null
|
|
92
|
+
if (toolCallId === null) return null
|
|
93
|
+
const entry = pending.get(toolCallId)
|
|
94
|
+
pending.delete(toolCallId)
|
|
95
|
+
const name = entry?.name ?? (typeof message.toolName === 'string' ? message.toolName : 'unknown')
|
|
96
|
+
const durationMs = entry !== undefined ? Math.max(0, ts - entry.startTs) : 0
|
|
97
|
+
const isError = message.isError === true
|
|
98
|
+
const text = readTextContent(message.content)
|
|
99
|
+
return {
|
|
100
|
+
cat: 'tool',
|
|
101
|
+
ts,
|
|
102
|
+
phase: 'end',
|
|
103
|
+
toolCallId,
|
|
104
|
+
name,
|
|
105
|
+
...(text !== null && text !== '' ? { result: text } : {}),
|
|
106
|
+
isError,
|
|
107
|
+
durationMs,
|
|
108
|
+
}
|
|
79
109
|
}
|
|
80
110
|
|
|
81
111
|
function* assistantEvents(
|
package/src/run/index.ts
CHANGED
|
@@ -5,9 +5,11 @@ import { LiveSessionRegistry } from '@/agent/live-sessions'
|
|
|
5
5
|
import { LiveSubagentRegistry } from '@/agent/live-subagents'
|
|
6
6
|
import type { SessionOrigin } from '@/agent/session-origin'
|
|
7
7
|
import {
|
|
8
|
+
awaitWithSubagentTimeout,
|
|
8
9
|
createSubagentConsumer,
|
|
9
10
|
defaultCreateSessionForSubagent,
|
|
10
11
|
invokeSubagent,
|
|
12
|
+
isSubagentTimeoutError,
|
|
11
13
|
type Subagent as InternalSubagent,
|
|
12
14
|
type SubagentConsumer,
|
|
13
15
|
type SubagentRegistry,
|
|
@@ -469,17 +471,31 @@ export async function startAgent({
|
|
|
469
471
|
options?.spawnedByOrigin !== undefined
|
|
470
472
|
? pluginsLoaded.permissions.resolveRole(options.spawnedByOrigin)
|
|
471
473
|
: undefined
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
474
|
+
const registry = pluginRuntime.get().subagents
|
|
475
|
+
try {
|
|
476
|
+
await awaitWithSubagentTimeout(
|
|
477
|
+
invokeSubagent(name, {
|
|
478
|
+
registry,
|
|
479
|
+
createSessionForSubagent,
|
|
480
|
+
agentDir: cwd,
|
|
481
|
+
userPrompt: '',
|
|
482
|
+
payload,
|
|
483
|
+
onProviderError: (message) => console.error(`[subagent] ${name}: LLM call failed: ${message}`),
|
|
484
|
+
...(options?.parentSessionId !== undefined ? { parentSessionId: options.parentSessionId } : {}),
|
|
485
|
+
...(spawnedByRole !== undefined ? { spawnedByRole } : {}),
|
|
486
|
+
...(options?.spawnedByOrigin !== undefined ? { spawnedByOrigin: options.spawnedByOrigin } : {}),
|
|
487
|
+
}),
|
|
488
|
+
name,
|
|
489
|
+
coalesceKey,
|
|
490
|
+
registry[name]?.timeoutMs,
|
|
491
|
+
)
|
|
492
|
+
} catch (err) {
|
|
493
|
+
if (isSubagentTimeoutError(err)) {
|
|
494
|
+
console.warn(`[subagent] ${coalesceKey} timed out after ${err.timeoutMs}ms; releasing coalesce key`)
|
|
495
|
+
return
|
|
496
|
+
}
|
|
497
|
+
throw err
|
|
498
|
+
}
|
|
483
499
|
} finally {
|
|
484
500
|
directSpawnInFlight.delete(coalesceKey)
|
|
485
501
|
}
|
package/typeclaw.schema.json
CHANGED
|
@@ -1177,7 +1177,8 @@
|
|
|
1177
1177
|
"idleMs": 60000,
|
|
1178
1178
|
"bufferBytes": 500000,
|
|
1179
1179
|
"injectionBudgetBytes": 16384,
|
|
1180
|
-
"spawnTimeoutMs": 50000
|
|
1180
|
+
"spawnTimeoutMs": 50000,
|
|
1181
|
+
"retrievalSpawnTimeoutMs": 30000
|
|
1181
1182
|
},
|
|
1182
1183
|
"type": "object",
|
|
1183
1184
|
"properties": {
|
|
@@ -1205,6 +1206,12 @@
|
|
|
1205
1206
|
"minimum": 1,
|
|
1206
1207
|
"maximum": 9007199254740991
|
|
1207
1208
|
},
|
|
1209
|
+
"retrievalSpawnTimeoutMs": {
|
|
1210
|
+
"default": 30000,
|
|
1211
|
+
"type": "integer",
|
|
1212
|
+
"minimum": 1,
|
|
1213
|
+
"maximum": 9007199254740991
|
|
1214
|
+
},
|
|
1208
1215
|
"dreaming": {
|
|
1209
1216
|
"type": "object",
|
|
1210
1217
|
"properties": {
|