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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "typeclaw",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
4
  "homepage": "https://github.com/typeclaw/typeclaw#readme",
5
5
  "bugs": {
6
6
  "url": "https://github.com/typeclaw/typeclaw/issues"
@@ -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 invokeSubagent(name, {
451
- registry,
452
- ...(createSessionForSubagent !== undefined ? { createSessionForSubagent } : {}),
453
- agentDir,
454
- userPrompt: '',
455
- payload: msg.payload,
456
- onProviderError: (message) => logger.error(`[subagent] ${key}: LLM call failed: ${message}`),
457
- ...(target.parentSessionId !== undefined ? { parentSessionId: target.parentSessionId } : {}),
458
- ...(target.spawnedByRole !== undefined ? { spawnedByRole: target.spawnedByRole } : {}),
459
- ...(spawnedByOrigin !== undefined ? { spawnedByOrigin } : {}),
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
- const message = err instanceof Error ? err.message : String(err)
463
- logger.error(`[subagent] ${key} failed: ${message}`)
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 { isNoReplySignal, isUpstreamEmptyResponseSentinel, type ChannelRouter } from '@/channels/router'
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: hint ? `${baseText}${hint}` : baseText }],
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
- if (countAfterSend === 2) {
219
- return 'this is your 2nd consecutive message in this conversation; continue only if the reply genuinely needs splitting.'
220
- }
221
- return `${countAfterSend}th consecutive message with no user reply; end your turn now unless the user explicitly asked for a multi-step response.`
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 { isNoReplySignal, isUpstreamEmptyResponseSentinel, type ChannelRouter } from '@/channels/router'
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 responseText = hints.length > 0 ? `${baseText}${hints.join(' ')}` : baseText
181
+ const body = hints.length > 0 ? `${baseText}${hints.join('')}` : baseText
167
182
  return {
168
- content: [{ type: 'text' as const, text: responseText }],
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
- `if breaking out of the thread was intentional, ignore this; otherwise prefer \`channel_reply\` ` +
197
- `or pass \`thread: ${JSON.stringify(origin.thread)}\` on your next channel_send.`
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
- if (countAfterSend === 2) {
245
- return 'this is your 2nd consecutive message in this conversation; continue only if the reply genuinely needs splitting.'
246
- }
247
- return `${countAfterSend}th consecutive message with no user reply; end your turn now unless the user explicitly asked for a multi-step response.`
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({ logger: subagentLogger }),
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(agentDir: string, logger: MigrationLogger): Promise<void> {
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
- ts: event.sent_at,
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
  }
@@ -257,7 +257,7 @@ export function createKakaoHistoryCallback(deps: {
257
257
  authorId,
258
258
  authorName,
259
259
  text: formatHistoryText(m),
260
- ts: m.sent_at,
260
+ ts: m.sent_at * 1000,
261
261
  isBot: selfId !== null && authorId === selfId,
262
262
  replyToBotMessageId: null,
263
263
  }
@@ -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 (isNoReplySignal(assistantText)) {
1746
- logger.info(`[channels] ${live.keyId} no_reply`)
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
- for (const b of batch) {
2120
- parts.push(formatAuthorLine(b.ts, b.authorId, b.authorName, b.authorIsBot, b.text))
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
  }
@@ -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
- await invokeSubagent(name, {
473
- registry: pluginRuntime.get().subagents,
474
- createSessionForSubagent,
475
- agentDir: cwd,
476
- userPrompt: '',
477
- payload,
478
- onProviderError: (message) => console.error(`[subagent] ${name}: LLM call failed: ${message}`),
479
- ...(options?.parentSessionId !== undefined ? { parentSessionId: options.parentSessionId } : {}),
480
- ...(spawnedByRole !== undefined ? { spawnedByRole } : {}),
481
- ...(options?.spawnedByOrigin !== undefined ? { spawnedByOrigin: options.spawnedByOrigin } : {}),
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
  }
@@ -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": {