@namzu/sdk 53.1.0 → 53.2.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.
Files changed (100) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/dist/compaction/index.d.ts +1 -1
  3. package/dist/compaction/index.d.ts.map +1 -1
  4. package/dist/compaction/index.js +1 -1
  5. package/dist/compaction/index.js.map +1 -1
  6. package/dist/compaction/manual.d.ts +5 -0
  7. package/dist/compaction/manual.d.ts.map +1 -1
  8. package/dist/compaction/manual.js +4 -4
  9. package/dist/compaction/manual.js.map +1 -1
  10. package/dist/compaction/salience/bm25.d.ts +1 -1
  11. package/dist/compaction/salience/bm25.d.ts.map +1 -1
  12. package/dist/compaction/salience/bm25.js +1 -2
  13. package/dist/compaction/salience/bm25.js.map +1 -1
  14. package/dist/compaction/salience/score.d.ts.map +1 -1
  15. package/dist/compaction/salience/score.js +1 -1
  16. package/dist/compaction/salience/score.js.map +1 -1
  17. package/dist/compaction/salience/tokenize.d.ts.map +1 -1
  18. package/dist/compaction/salience/tokenize.js +16 -2
  19. package/dist/compaction/salience/tokenize.js.map +1 -1
  20. package/dist/compaction/summary.d.ts +12 -2
  21. package/dist/compaction/summary.d.ts.map +1 -1
  22. package/dist/compaction/summary.js +13 -3
  23. package/dist/compaction/summary.js.map +1 -1
  24. package/dist/compaction/tool-result-editing.d.ts.map +1 -1
  25. package/dist/compaction/tool-result-editing.js +9 -6
  26. package/dist/compaction/tool-result-editing.js.map +1 -1
  27. package/dist/manager/agent/lifecycle.d.ts +15 -0
  28. package/dist/manager/agent/lifecycle.d.ts.map +1 -1
  29. package/dist/manager/agent/lifecycle.js +169 -1
  30. package/dist/manager/agent/lifecycle.js.map +1 -1
  31. package/dist/public-runtime.d.ts +2 -1
  32. package/dist/public-runtime.d.ts.map +1 -1
  33. package/dist/public-runtime.js +2 -1
  34. package/dist/public-runtime.js.map +1 -1
  35. package/dist/runtime/query/executor.d.ts +2 -0
  36. package/dist/runtime/query/executor.d.ts.map +1 -1
  37. package/dist/runtime/query/executor.js +21 -1
  38. package/dist/runtime/query/executor.js.map +1 -1
  39. package/dist/runtime/query/iteration/phases/compaction.d.ts.map +1 -1
  40. package/dist/runtime/query/iteration/phases/compaction.js +6 -2
  41. package/dist/runtime/query/iteration/phases/compaction.js.map +1 -1
  42. package/dist/runtime/query/tool-output-budget.d.ts +30 -2
  43. package/dist/runtime/query/tool-output-budget.d.ts.map +1 -1
  44. package/dist/runtime/query/tool-output-budget.js +43 -10
  45. package/dist/runtime/query/tool-output-budget.js.map +1 -1
  46. package/dist/scheduler/local.d.ts +2 -1
  47. package/dist/scheduler/local.d.ts.map +1 -1
  48. package/dist/scheduler/local.js +8 -0
  49. package/dist/scheduler/local.js.map +1 -1
  50. package/dist/store/evidence/disk.d.ts +3 -2
  51. package/dist/store/evidence/disk.d.ts.map +1 -1
  52. package/dist/store/evidence/disk.js +23 -3
  53. package/dist/store/evidence/disk.js.map +1 -1
  54. package/dist/store/evidence/source-text.d.ts +20 -0
  55. package/dist/store/evidence/source-text.d.ts.map +1 -1
  56. package/dist/store/evidence/source-text.js +29 -2
  57. package/dist/store/evidence/source-text.js.map +1 -1
  58. package/dist/tools/builtins/read-file.d.ts.map +1 -1
  59. package/dist/tools/builtins/read-file.js +7 -2
  60. package/dist/tools/builtins/read-file.js.map +1 -1
  61. package/dist/tools/retained-output.d.ts +12 -0
  62. package/dist/tools/retained-output.d.ts.map +1 -0
  63. package/dist/tools/retained-output.js +39 -0
  64. package/dist/tools/retained-output.js.map +1 -0
  65. package/dist/types/agent/manager.d.ts +48 -0
  66. package/dist/types/agent/manager.d.ts.map +1 -1
  67. package/dist/types/agent/scheduler.d.ts +20 -0
  68. package/dist/types/agent/scheduler.d.ts.map +1 -1
  69. package/dist/types/agent/task.d.ts +2 -0
  70. package/dist/types/agent/task.d.ts.map +1 -1
  71. package/dist/types/session/records.d.ts +25 -0
  72. package/dist/types/session/records.d.ts.map +1 -1
  73. package/dist/types/session/records.js +16 -0
  74. package/dist/types/session/records.js.map +1 -1
  75. package/dist/types/tool/index.d.ts +8 -0
  76. package/dist/types/tool/index.d.ts.map +1 -1
  77. package/dist/types/tool/index.js.map +1 -1
  78. package/package.json +1 -1
  79. package/src/compaction/index.ts +6 -1
  80. package/src/compaction/manual.ts +15 -3
  81. package/src/compaction/salience/bm25.ts +4 -2
  82. package/src/compaction/salience/score.ts +3 -1
  83. package/src/compaction/salience/tokenize.ts +16 -2
  84. package/src/compaction/summary.ts +17 -3
  85. package/src/compaction/tool-result-editing.ts +12 -9
  86. package/src/manager/agent/lifecycle.ts +181 -1
  87. package/src/public-runtime.ts +2 -1
  88. package/src/runtime/query/executor.ts +25 -1
  89. package/src/runtime/query/iteration/phases/compaction.ts +13 -2
  90. package/src/runtime/query/tool-output-budget.ts +65 -11
  91. package/src/scheduler/local.ts +14 -1
  92. package/src/store/evidence/disk.ts +28 -3
  93. package/src/store/evidence/source-text.ts +38 -2
  94. package/src/tools/builtins/read-file.ts +7 -2
  95. package/src/tools/retained-output.ts +38 -0
  96. package/src/types/agent/manager.ts +54 -0
  97. package/src/types/agent/scheduler.ts +21 -0
  98. package/src/types/agent/task.ts +2 -0
  99. package/src/types/session/records.ts +16 -0
  100. package/src/types/tool/index.ts +8 -0
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/types/tool/index.ts"],"names":[],"mappings":"AAizCA,cAAc,aAAa,CAAA"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/types/tool/index.ts"],"names":[],"mappings":"AAyzCA,cAAc,aAAa,CAAA"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@namzu/sdk",
3
- "version": "53.1.0",
3
+ "version": "53.2.1",
4
4
  "description": "Open-source AI kernel for TypeScript: runs an agent the way an operating system runs a process, with an identity and a budget, scheduled tool calls and checkpointed turns.",
5
5
  "license": "FSL-1.1-MIT",
6
6
  "engines": {
@@ -71,7 +71,12 @@ export type { ToolResultEditConfig, ToolResultEditOutcome } from './tool-result-
71
71
  // can offer, rather than something that only happens when a threshold trips.
72
72
  export { compactNow, compactRegion } from './manual.js'
73
73
  export type { CompactionResult, CompactNowInput, CompactRegionInput } from './manual.js'
74
- export { COMPACTION_HEADER, buildCompactionMessage, isCompactionMessage } from './summary.js'
74
+ export {
75
+ COMPACTION_HEADER,
76
+ COMPACTION_RECOVERY_NOTE,
77
+ buildCompactionMessage,
78
+ isCompactionMessage,
79
+ } from './summary.js'
75
80
 
76
81
  // The salience-scored working set: every message scored, the least salient
77
82
  // evicted first. See `docs/sdk/salience-working-set.md`.
@@ -63,6 +63,11 @@ export interface CompactNowInput extends CompactionVerificationOptions {
63
63
  * a no-op. The callback owns persistence and must not mutate the messages.
64
64
  */
65
65
  readonly onShed?: (messages: readonly Message[]) => void | Promise<void>
66
+ /**
67
+ * A paragraph added to the end of the summary, for a host that can point the
68
+ * model at the originals ({@link COMPACTION_RECOVERY_NOTE}). Absent adds none.
69
+ */
70
+ readonly recoveryNote?: string
66
71
  }
67
72
 
68
73
  async function publishManualCompaction(
@@ -100,12 +105,13 @@ function splice(
100
105
  summaryBody: string,
101
106
  retainedOlder: readonly Message[],
102
107
  recentMessages: readonly Message[],
108
+ recoveryNote?: string,
103
109
  ): { messages: Message[]; summary: Message } {
104
110
  // A host-triggered pass has no turn-scoped WorkingStateManager to carry the
105
111
  // summary into a later query. Pin this summary itself: until a future turn
106
112
  // has rebuilt equivalent state, this message is the only surviving record
107
113
  // of what the pass removed.
108
- const summary = { ...buildCompactionMessage(summaryBody), retain: true }
114
+ const summary = { ...buildCompactionMessage(summaryBody, recoveryNote), retain: true }
109
115
  return { messages: [...preservedSystem, summary, ...retainedOlder, ...recentMessages], summary }
110
116
  }
111
117
 
@@ -205,7 +211,13 @@ export async function compactNow(input: CompactNowInput): Promise<CompactionResu
205
211
  },
206
212
  )
207
213
 
208
- const { messages, summary } = splice(preservedSystem, body, retainedOlder, plan.recentMessages)
214
+ const { messages, summary } = splice(
215
+ preservedSystem,
216
+ body,
217
+ retainedOlder,
218
+ plan.recentMessages,
219
+ input.recoveryNote,
220
+ )
209
221
  return publishManualCompaction(input, {
210
222
  messages,
211
223
  shed: input.messages.length - messages.length,
@@ -294,7 +306,7 @@ export async function compactRegion(input: CompactRegionInput): Promise<Compacti
294
306
 
295
307
  // Same cross-run ownership as compactNow: a selected region has been
296
308
  // replaced outside a turn, so its only surviving account is pinned.
297
- const summary = { ...buildCompactionMessage(body), retain: true }
309
+ const summary = { ...buildCompactionMessage(body, input.recoveryNote), retain: true }
298
310
  const out = [...messages.slice(0, start), summary, ...retainedSelected, ...messages.slice(end)]
299
311
 
300
312
  // Checked after the splice, not only before it. The edges being
@@ -27,8 +27,10 @@ export interface Bm25Index {
27
27
  const K1 = 1.2
28
28
  const B = 0.75
29
29
 
30
- export function indexDocument(text: string): Bm25Document {
31
- const tokens = tokenize(text)
30
+ export function indexDocument(
31
+ text: string,
32
+ tokens: readonly string[] = tokenize(text),
33
+ ): Bm25Document {
32
34
  return { tf: termFrequencies(tokens), length: tokens.length }
33
35
  }
34
36
 
@@ -132,7 +132,9 @@ export function scoreMessages(
132
132
  const texts = messages.map(messageText)
133
133
  const richContent = messages.map(hasRichContent)
134
134
  const tokenLists = texts.map(tokenize)
135
- const documents: Bm25Document[] = texts.map(indexDocument)
135
+ const documents: Bm25Document[] = texts.map((text, i) =>
136
+ indexDocument(text, tokenLists[i] as string[]),
137
+ )
136
138
  const index = buildIndex(documents)
137
139
  const goalTokens = tokenize(options.goal)
138
140
 
@@ -46,10 +46,24 @@ const STOP_WORDS: ReadonlySet<string> = new Set([
46
46
 
47
47
  const WORD = /[\p{L}\p{N}_$][\p{L}\p{N}_$.\-/\\:]*/gu
48
48
 
49
+ const SEPARATORS: ReadonlySet<string> = new Set(['.', '-', '/', '\\', ':'])
50
+
51
+ // A linear scan from each end: a `[sep]+$` regex is retried from every start of a
52
+ // long separator run inside a token and fails at the end each time, quadratically.
53
+ function trimSeparators(token: string): string {
54
+ let start = 0
55
+ let end = token.length
56
+ while (start < end && SEPARATORS.has(token[start] as string)) start++
57
+ while (end > start && SEPARATORS.has(token[end - 1] as string)) end--
58
+ return start === 0 && end === token.length ? token : token.slice(start, end)
59
+ }
60
+
49
61
  function splitIdentifier(raw: string): string[] {
50
62
  const parts = raw
51
63
  .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
52
- .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
64
+ // Lookaround keeps this linear: a capturing `([A-Z]+)([A-Z][a-z])` backtracks
65
+ // quadratically on a long run of capitals that never reaches a lower-case letter.
66
+ .replace(/(?<=[A-Z])(?=[A-Z][a-z])/g, ' ')
53
67
  .split(/[_\s]+/)
54
68
  .filter((p) => p.length > 0)
55
69
  return parts.length > 1 ? parts : []
@@ -59,7 +73,7 @@ function splitIdentifier(raw: string): string[] {
59
73
  export function tokenize(text: string): string[] {
60
74
  const out: string[] = []
61
75
  for (const match of text.matchAll(WORD)) {
62
- const raw = match[0].replace(/^[.\-/\\:]+|[.\-/\\:]+$/g, '')
76
+ const raw = trimSeparators(match[0])
63
77
  if (raw.length === 0) continue
64
78
  const whole = raw.toLowerCase()
65
79
  if (!STOP_WORDS.has(whole)) out.push(whole)
@@ -25,10 +25,24 @@ export function isCompactionMessage(content: string | null | undefined): boolean
25
25
  return typeof content === 'string' && content.startsWith(COMPACTION_HEADER)
26
26
  }
27
27
 
28
- /** The summary, as the system message that replaces what it summarises. */
29
- export function buildCompactionMessage(body: string): Message {
28
+ /**
29
+ * The line that tells the model where exact earlier text can still be had. A
30
+ * host that mounts `search_conversation` and `read_conversation` adds it to
31
+ * every summary; a host without them adds nothing, because a pointer to a tool
32
+ * that is not there is worse than none.
33
+ */
34
+ export const COMPACTION_RECOVERY_NOTE =
35
+ 'Earlier output was cut from this summary. To get exact earlier text, call search_conversation, then read_conversation.'
36
+
37
+ /**
38
+ * The summary, as the system message that replaces what it summarises.
39
+ * `recoveryNote` is appended as its own paragraph.
40
+ */
41
+ export function buildCompactionMessage(body: string, recoveryNote?: string): Message {
30
42
  return {
31
- ...createSystemMessage(`${COMPACTION_HEADER}\n\n${body}`),
43
+ ...createSystemMessage(
44
+ `${COMPACTION_HEADER}\n\n${body}${recoveryNote ? `\n\n${recoveryNote}` : ''}`,
45
+ ),
32
46
  source: { type: 'compaction-summary' },
33
47
  }
34
48
  }
@@ -1,5 +1,8 @@
1
1
  import { CHARS_PER_TOKEN } from '../constants/limits.js'
2
- import { SPILL_MARKER } from '../runtime/query/tool-output-budget.js'
2
+ import {
3
+ describeRetainedOutputRecovery,
4
+ retainedOutputPointer,
5
+ } from '../runtime/query/tool-output-budget.js'
3
6
  import { toolResultToText } from '../types/message/content.js'
4
7
  import type { Message, ToolMessage, ToolResultBlock } from '../types/message/index.js'
5
8
  import { findRetainedIndices } from './retention.js'
@@ -68,8 +71,8 @@ const KEEP_TAIL_CHARS = 200
68
71
  * back to it. Replacing the whole content destroyed the cheapest recovery
69
72
  * route precisely for the largest results — and then told the model to
70
73
  * call the tool again, which is advice to re-run something that returned
71
- * megabytes. Keeping one line preserves a `read`/`grep` route worth far
72
- * more than the line costs.
74
+ * megabytes. Keeping the line and its recovery sentence preserves a route
75
+ * worth far more than the two lines cost.
73
76
  *
74
77
  * A HEAD AND TAIL, because a result is not uniformly valuable: what a
75
78
  * model needs from a long output is usually near one end, and keeping a
@@ -86,12 +89,12 @@ function clearedPlaceholder(
86
89
  `${CLEARED_PREFIX} ${toolName} returned ${size.toLocaleString('en-US')} characters, cleared to reclaim context.`,
87
90
  ]
88
91
 
89
- const spillLine = text.split('\n').find((line) => line.startsWith(SPILL_MARKER))
90
- if (spillLine) {
91
- parts.push(
92
- spillLine,
93
- 'Read a window of it with `read` (offset/limit) or search it with `grep`.',
94
- )
92
+ // The pointer is the spill line with the sentence that came with it, so the
93
+ // route is stated once, where the output was cut, and never contradicted here.
94
+ const pointer = retainedOutputPointer(text)
95
+ if (pointer) {
96
+ // A pointer written before the sentence existed gets the neutral one.
97
+ parts.push(pointer.includes('\n') ? pointer : `${pointer}\n${describeRetainedOutputRecovery()}`)
95
98
  } else {
96
99
  parts.push(
97
100
  'Full result unavailable here. Recover from a saved artifact or a read-only observation; do not repeat a state-changing action to recover its output.',
@@ -23,6 +23,11 @@ import type {
23
23
  AgentLifecycleEvent,
24
24
  AgentLifecycleListener,
25
25
  } from '../../types/agent/lifecycle-event.js'
26
+ import type {
27
+ RestoreChildOptions,
28
+ RestoreChildRefusal,
29
+ RestoreChildResult,
30
+ } from '../../types/agent/manager.js'
26
31
  import type {
27
32
  AgentManagerConfig,
28
33
  AgentTask,
@@ -53,7 +58,12 @@ import { type Logger, resolveLogger } from '../../utils/logger.js'
53
58
  import { requireOpenProject } from '../project/lifecycle.js'
54
59
  import { type TopicManagerDependency, resolveTopicManager } from '../topic/dependency.js'
55
60
  import type { TopicManager } from '../topic/lifecycle.js'
56
- import { registerChildSessionLog, writeChildSessionMeta } from './child-session.js'
61
+ import {
62
+ childSessionEnded,
63
+ readChildSessionMeta,
64
+ registerChildSessionLog,
65
+ writeChildSessionMeta,
66
+ } from './child-session.js'
57
67
 
58
68
  /**
59
69
  * Dependencies threaded into {@link AgentManager}. Phase 6 promoted the
@@ -155,6 +165,7 @@ interface ChildSpawnRecord {
155
165
  readonly toolCallId: string
156
166
  readonly agentType: string
157
167
  readonly description: string
168
+ readonly continuation?: NonNullable<ChildSessionMeta['continuation']>
158
169
  }
159
170
  /** Removes the child's log from the process-local lookup. */
160
171
  releaseLog?: () => void
@@ -1236,6 +1247,7 @@ export class AgentManager {
1236
1247
  description: placement.description,
1237
1248
  status: 'running',
1238
1249
  createdAt: placement.createdAt,
1250
+ ...(placement.continuation ? { continuation: placement.continuation } : {}),
1239
1251
  }
1240
1252
  }
1241
1253
 
@@ -1296,6 +1308,14 @@ export class AgentManager {
1296
1308
  toolCallId: spawnToolCallId(taskId),
1297
1309
  agentType: options.agentId,
1298
1310
  description: describeSpawn(options),
1311
+ ...(options.continuationProfile
1312
+ ? {
1313
+ continuation: {
1314
+ workspace: options.workspace?.mode === 'isolated' ? 'isolated' : 'shared',
1315
+ profile: { ...options.continuationProfile },
1316
+ },
1317
+ }
1318
+ : {}),
1299
1319
  }
1300
1320
  await writeChildSessionMeta(spawnRecord.placement.metaPath, this.childMeta(spawnRecord))
1301
1321
  return DiskSessionLog.at(paths, {
@@ -1796,6 +1816,166 @@ export class AgentManager {
1796
1816
  })
1797
1817
  }
1798
1818
 
1819
+ /**
1820
+ * Make a child saved by an earlier process continuable again.
1821
+ *
1822
+ * Reads the child's meta document and log from the project layout under the
1823
+ * invoking parent, checks they describe a settled, shared-workspace child of
1824
+ * this very parent session, then re-creates the in-memory session and edge
1825
+ * and the retained conversation that {@link provisionContinuation} requires.
1826
+ * It runs nothing; the caller continues the child with `sendMessage` and
1827
+ * `resumeSessionId`, so the invoking turn's authority, limits and review
1828
+ * channel apply exactly as for a child that finished in this process.
1829
+ */
1830
+ async restoreChildConversation(
1831
+ options: RestoreChildOptions,
1832
+ context: AgentTaskContext,
1833
+ ): Promise<RestoreChildResult> {
1834
+ const refuse = (reason: RestoreChildRefusal): RestoreChildResult => ({
1835
+ status: 'refused',
1836
+ reason,
1837
+ })
1838
+ const id = options.childSessionId
1839
+ const retained = this.childConversations.get(id)
1840
+ if (retained?.activeSubSessionId) return refuse('running')
1841
+ const paths =
1842
+ context.childStorage?.kind === 'disk' ? context.childStorage.paths : this.deps.paths
1843
+ if (!paths) return refuse('no-layout')
1844
+ const store = this.deps.sessionStore
1845
+ if (store.supportsInvocationSummaries !== true || store.supportsOwnerVersionCas !== true)
1846
+ return refuse('not-resumable')
1847
+ const ancestry = await store.getAncestry(context.sessionId, context.tenantId)
1848
+ const parentLocator: SessionLocator = {
1849
+ sessionId: context.sessionId,
1850
+ ancestors: ancestry.slice(0, -1),
1851
+ }
1852
+ let meta: ChildSessionMeta | null
1853
+ try {
1854
+ meta = await readChildSessionMeta(paths.subagentMeta(parentLocator, id))
1855
+ } catch {
1856
+ return refuse('not-saved')
1857
+ }
1858
+ if (!meta) return refuse('not-saved')
1859
+ if (meta.sessionId !== id || meta.parentSessionId !== context.sessionId)
1860
+ return refuse('foreign')
1861
+ const continuation = meta.continuation
1862
+ if (!continuation || continuation.workspace !== 'shared') return refuse('not-resumable')
1863
+ const agentId = options.agentFor(continuation.profile)
1864
+ if (agentId === undefined || !this.registry.has(agentId)) return refuse('not-resumable')
1865
+ const log = retained?.log ?? DiskSessionLog.at(paths, { sessionId: id, ancestors: ancestry })
1866
+ // The child's own log wins over its meta document: a child that never
1867
+ // recorded the end of a turn died mid-turn, and what it did is unknown.
1868
+ const ended = await childSessionEnded(log)
1869
+ if (!ended) return refuse('unsettled')
1870
+ if (ended.status === 'cancelled' || meta.status === 'cancelled') return refuse('stopped')
1871
+ // A log written under another project or topic cannot be continued here:
1872
+ // the child's turn would refuse to open it.
1873
+ const started = await log.head()
1874
+ if (started === null) return refuse('unsettled')
1875
+ for await (const { record } of log.read({ mode: 'tolerant' })) {
1876
+ if (record.type !== 'session_started') continue
1877
+ if (record.projectId !== context.projectId || record.topicId !== context.topicId)
1878
+ return refuse('foreign')
1879
+ break
1880
+ }
1881
+ const placement = {
1882
+ paths,
1883
+ metaPath: paths.subagentMeta(parentLocator, id),
1884
+ createdAt: meta.createdAt,
1885
+ toolCallId: meta.toolCallId,
1886
+ agentType: meta.agentType,
1887
+ description: meta.description,
1888
+ continuation,
1889
+ }
1890
+ const restored: RestoreChildResult = {
1891
+ status: 'restored',
1892
+ childSessionId: id,
1893
+ agentId,
1894
+ profile: continuation.profile,
1895
+ description: meta.description,
1896
+ }
1897
+ if (retained) return restored
1898
+ if (this.childConversations.size >= RETAINED_CHILD_CONVERSATION_CAP) {
1899
+ const oldest = [...this.childConversations].find(([, value]) => !value.activeSubSessionId)
1900
+ if (!oldest) return refuse('full')
1901
+ this.childConversations.delete(oldest[0])
1902
+ }
1903
+ const childActor: ActorRef = {
1904
+ kind: 'agent',
1905
+ agentId,
1906
+ tenantId: context.tenantId,
1907
+ parentActor: context.parentActor,
1908
+ }
1909
+ let child = await store.getSession(id, context.tenantId)
1910
+ let createdChild = false
1911
+ let edge: Awaited<ReturnType<typeof store.createSubSession>> | undefined
1912
+ let createdEdge = false
1913
+ try {
1914
+ if (child) {
1915
+ // A store that outlived the process still holds the child and its edge.
1916
+ if (
1917
+ child.status === 'active' ||
1918
+ child.status === 'archived' ||
1919
+ child.status === 'locked' ||
1920
+ child.projectId !== context.projectId ||
1921
+ child.topicId !== context.topicId ||
1922
+ child.currentActor?.kind !== 'agent' ||
1923
+ child.currentActor.agentId !== agentId
1924
+ )
1925
+ return refuse('running')
1926
+ } else {
1927
+ child = await store.createSession(
1928
+ { id, topicId: context.topicId, projectId: context.projectId, currentActor: childActor },
1929
+ context.tenantId,
1930
+ )
1931
+ createdChild = true
1932
+ }
1933
+ edge = (await store.getChildren(context.sessionId, context.tenantId)).find(
1934
+ (candidate) => candidate.childSessionId === id,
1935
+ )
1936
+ if (!edge) {
1937
+ edge = await store.createSubSession(
1938
+ {
1939
+ parentSessionId: context.sessionId,
1940
+ childSessionId: id,
1941
+ kind: 'agent_spawn',
1942
+ spawnedBy: context.parentActor,
1943
+ failureMode: 'delegate',
1944
+ completionMode: 'summary_ref',
1945
+ },
1946
+ context.tenantId,
1947
+ )
1948
+ createdEdge = true
1949
+ }
1950
+ } catch (error) {
1951
+ if (edge && createdEdge)
1952
+ await store.deleteSubSession(edge.id, context.tenantId).catch(() => undefined)
1953
+ if (child && createdChild)
1954
+ await store.deleteSession(id, context.tenantId).catch(() => undefined)
1955
+ throw error
1956
+ }
1957
+ this.childConversations.set(id, {
1958
+ definition: this.registry.getOrThrow(agentId),
1959
+ origin: {
1960
+ subSessionId: edge.id,
1961
+ childSessionId: id,
1962
+ tenantId: context.tenantId,
1963
+ parentSessionId: context.sessionId,
1964
+ parentTurnId: meta.parentTurnId,
1965
+ rootSessionId: meta.rootSessionId,
1966
+ ancestry,
1967
+ childDepth: meta.depth,
1968
+ childOwnerVersion: child.ownerVersion,
1969
+ placement,
1970
+ },
1971
+ topicId: context.topicId,
1972
+ projectId: context.projectId,
1973
+ cwd: resolve(options.workingDirectory),
1974
+ log,
1975
+ })
1976
+ return restored
1977
+ }
1978
+
1799
1979
  private releaseChildConversation(record: ChildSpawnRecord): void {
1800
1980
  const conversation = this.childConversations.get(record.childSessionId)
1801
1981
  if (conversation?.activeSubSessionId !== record.subSessionId) return
@@ -1275,7 +1275,7 @@ export { compactNow, compactRegion } from './compaction/manual.js'
1275
1275
  // A host persisting manual compaction has to recognise the system message on
1276
1276
  // resume. Export the identity predicate rather than making every host copy the
1277
1277
  // marker string and eventually disagree with the writer.
1278
- export { isCompactionMessage } from './compaction/summary.js'
1278
+ export { COMPACTION_RECOVERY_NOTE, isCompactionMessage } from './compaction/summary.js'
1279
1279
  export {
1280
1280
  DEFAULT_SALIENCE_CONFIG,
1281
1281
  DEFAULT_SALIENCE_WEIGHTS,
@@ -1639,6 +1639,7 @@ export {
1639
1639
  createSessionEvidenceSource,
1640
1640
  createSessionTextEvidenceSource,
1641
1641
  } from './store/evidence/disk.js'
1642
+ export { SessionEvidenceOffsetError } from './store/evidence/source-text.js'
1642
1643
  export {
1643
1644
  classifyEvidenceSource,
1644
1645
  EVIDENCE_RECORD_GUIDANCE,
@@ -17,6 +17,7 @@ import { sandboxShellSpawn, withoutBashStartup } from '../../tools/command-shell
17
17
  import { isAlwaysDestructive } from '../../tools/defineTool.js'
18
18
  import { createFileReadTracker } from '../../tools/file-read-tracker.js'
19
19
  import { pathOutsideRoots, toolRoots } from '../../tools/paths.js'
20
+ import { resolveRetainedOutput } from '../../tools/retained-output.js'
20
21
  import type { ToolManager } from '../../toolsets/manager.js'
21
22
  import type { ToolSourceRef } from '../../toolsets/types.js'
22
23
  import type { ToolResultGuardrailSpec } from '../../types/guardrail/index.js'
@@ -81,6 +82,7 @@ import { ToolCallBudget, assertMaxToolCalls } from './tool-call-budget.js'
81
82
  import {
82
83
  DEFAULT_MAX_TOOL_OUTPUT_CHARS,
83
84
  DEFAULT_MAX_TOOL_OUTPUT_CHARS_PER_MESSAGE,
85
+ type RetainedOutputRoutes,
84
86
  type ToolOutputBudgetResult,
85
87
  applyToolOutputBudget,
86
88
  describeDroppedContent,
@@ -635,6 +637,17 @@ export function deniedToolOutput(toolName: string, reason: string): string {
635
637
  }
636
638
 
637
639
  export class ToolExecutor {
640
+ /** The recovery routes this turn actually has, so a pointer never names a missing tool. */
641
+ private recoveryRoutes(): RetainedOutputRoutes {
642
+ const tools = this.config.tools
643
+ return {
644
+ read: tools.get('read') !== undefined,
645
+ conversation:
646
+ tools.get('search_conversation') !== undefined &&
647
+ tools.get('read_conversation') !== undefined,
648
+ }
649
+ }
650
+
638
651
  private outputDirectory(): string | undefined {
639
652
  return typeof this.config.toolOutputDir === 'function'
640
653
  ? this.config.toolOutputDir()
@@ -1079,7 +1092,11 @@ export class ToolExecutor {
1079
1092
  ) {
1080
1093
  const value = input[tool.pathArgument]
1081
1094
  if (typeof value === 'string') {
1082
- const outside = await pathOutsideRoots(toolRoots(this.config), value)
1095
+ // This session's own retained output is not "outside": it is where the
1096
+ // result that was cut told the model to look.
1097
+ const retained =
1098
+ tool.name === 'read' && (await resolveRetainedOutput(this.outputDirectory(), value))
1099
+ const outside = retained ? undefined : await pathOutsideRoots(toolRoots(this.config), value)
1083
1100
  if (outside !== undefined) outsidePaths = [outside]
1084
1101
  }
1085
1102
  }
@@ -1526,6 +1543,7 @@ export class ToolExecutor {
1526
1543
  {
1527
1544
  maxChars,
1528
1545
  spillDir: this.outputDirectory(),
1546
+ recoveryRoutes: this.recoveryRoutes(),
1529
1547
  onError: (message) =>
1530
1548
  this.log.warn('Failed to retain a tool output replaced for the message budget', {
1531
1549
  [NAMZU.TURN_ID]: this.config.turnId,
@@ -1806,6 +1824,7 @@ export class ToolExecutor {
1806
1824
  maxChars: this.config.maxToolOutputChars ?? DEFAULT_MAX_TOOL_OUTPUT_CHARS,
1807
1825
  retainedPreviewChars: this.config.retainedToolPreviewChars,
1808
1826
  spillDir: this.outputDirectory(),
1827
+ recoveryRoutes: this.recoveryRoutes(),
1809
1828
  onError: (message) =>
1810
1829
  this.log.warn('Failed to spill oversized nested tool output', {
1811
1830
  [NAMZU.TURN_ID]: this.config.turnId,
@@ -1901,6 +1920,9 @@ export class ToolExecutor {
1901
1920
  ...(this.config.additionalDirectories?.length
1902
1921
  ? { additionalDirectories: this.config.additionalDirectories }
1903
1922
  : {}),
1923
+ ...(this.outputDirectory()
1924
+ ? { retainedOutputDirectory: this.outputDirectory() as string }
1925
+ : {}),
1904
1926
  abortSignal: this.config.abortSignal,
1905
1927
  env: this.config.env,
1906
1928
  log: (level, message) => this.log[level](message),
@@ -2287,6 +2309,7 @@ export class ToolExecutor {
2287
2309
  maxChars: maxToolOutputChars,
2288
2310
  retainedPreviewChars: this.config.retainedToolPreviewChars,
2289
2311
  spillDir: this.outputDirectory(),
2312
+ recoveryRoutes: this.recoveryRoutes(),
2290
2313
  onError: (message) =>
2291
2314
  this.log.warn('Failed to retain original tool output', {
2292
2315
  [NAMZU.TURN_ID]: this.config.turnId,
@@ -3177,6 +3200,7 @@ export class ToolExecutor {
3177
3200
  spillDir: this.outputDirectory()
3178
3201
  ? join(this.outputDirectory() as string, 'content')
3179
3202
  : undefined,
3203
+ recoveryRoutes: this.recoveryRoutes(),
3180
3204
  onError: (message) =>
3181
3205
  this.log.warn('Failed to spill oversized model tool content', {
3182
3206
  [NAMZU.TURN_ID]: this.config.turnId,
@@ -13,7 +13,11 @@ import type { ContextReducer, ContextReduction } from '../../../../compaction/re
13
13
  import { createSlidingWindowReducer } from '../../../../compaction/reducer.js'
14
14
  import { findRetainedIndices } from '../../../../compaction/retention.js'
15
15
  import { serializeState } from '../../../../compaction/serializer.js'
16
- import { buildCompactionMessage, isCompactionMessage } from '../../../../compaction/summary.js'
16
+ import {
17
+ COMPACTION_RECOVERY_NOTE,
18
+ buildCompactionMessage,
19
+ isCompactionMessage,
20
+ } from '../../../../compaction/summary.js'
17
21
  import { estimateMessagesTokens } from '../../../../compaction/token-estimate.js'
18
22
  import { buildVerifiedSummaryWithBoundedProvider } from '../../../../compaction/verifier.js'
19
23
  import type { CompactionConfig } from '../../../../config/runtime.js'
@@ -999,7 +1003,14 @@ async function runCompactionCheckInner(
999
1003
  compactedContent = serializeState(manager.getState())
1000
1004
  }
1001
1005
 
1002
- const compactionMessage = buildCompactionMessage(compactedContent)
1006
+ // The pointer is only worth a line when both tools it names are mounted.
1007
+ const mounted = new Set(ctx.tools?.listNames?.() ?? [])
1008
+ const compactionMessage = buildCompactionMessage(
1009
+ compactedContent,
1010
+ mounted.has('search_conversation') && mounted.has('read_conversation')
1011
+ ? COMPACTION_RECOVERY_NOTE
1012
+ : undefined,
1013
+ )
1003
1014
 
1004
1015
  // Drop a replaceable PRIOR `[COMPACTED CONTEXT]` summary from this turn's
1005
1016
  // leading floor — `serializeState` is cumulative, so the new summary