@namzu/sdk 53.1.0 → 53.2.0

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 (87) hide show
  1. package/CHANGELOG.md +13 -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/summary.d.ts +12 -2
  11. package/dist/compaction/summary.d.ts.map +1 -1
  12. package/dist/compaction/summary.js +13 -3
  13. package/dist/compaction/summary.js.map +1 -1
  14. package/dist/compaction/tool-result-editing.d.ts.map +1 -1
  15. package/dist/compaction/tool-result-editing.js +9 -6
  16. package/dist/compaction/tool-result-editing.js.map +1 -1
  17. package/dist/manager/agent/lifecycle.d.ts +15 -0
  18. package/dist/manager/agent/lifecycle.d.ts.map +1 -1
  19. package/dist/manager/agent/lifecycle.js +169 -1
  20. package/dist/manager/agent/lifecycle.js.map +1 -1
  21. package/dist/public-runtime.d.ts +2 -1
  22. package/dist/public-runtime.d.ts.map +1 -1
  23. package/dist/public-runtime.js +2 -1
  24. package/dist/public-runtime.js.map +1 -1
  25. package/dist/runtime/query/executor.d.ts +2 -0
  26. package/dist/runtime/query/executor.d.ts.map +1 -1
  27. package/dist/runtime/query/executor.js +21 -1
  28. package/dist/runtime/query/executor.js.map +1 -1
  29. package/dist/runtime/query/iteration/phases/compaction.d.ts.map +1 -1
  30. package/dist/runtime/query/iteration/phases/compaction.js +6 -2
  31. package/dist/runtime/query/iteration/phases/compaction.js.map +1 -1
  32. package/dist/runtime/query/tool-output-budget.d.ts +30 -2
  33. package/dist/runtime/query/tool-output-budget.d.ts.map +1 -1
  34. package/dist/runtime/query/tool-output-budget.js +43 -10
  35. package/dist/runtime/query/tool-output-budget.js.map +1 -1
  36. package/dist/scheduler/local.d.ts +2 -1
  37. package/dist/scheduler/local.d.ts.map +1 -1
  38. package/dist/scheduler/local.js +8 -0
  39. package/dist/scheduler/local.js.map +1 -1
  40. package/dist/store/evidence/disk.d.ts +3 -2
  41. package/dist/store/evidence/disk.d.ts.map +1 -1
  42. package/dist/store/evidence/disk.js +23 -3
  43. package/dist/store/evidence/disk.js.map +1 -1
  44. package/dist/store/evidence/source-text.d.ts +20 -0
  45. package/dist/store/evidence/source-text.d.ts.map +1 -1
  46. package/dist/store/evidence/source-text.js +29 -2
  47. package/dist/store/evidence/source-text.js.map +1 -1
  48. package/dist/tools/builtins/read-file.d.ts.map +1 -1
  49. package/dist/tools/builtins/read-file.js +7 -2
  50. package/dist/tools/builtins/read-file.js.map +1 -1
  51. package/dist/tools/retained-output.d.ts +12 -0
  52. package/dist/tools/retained-output.d.ts.map +1 -0
  53. package/dist/tools/retained-output.js +39 -0
  54. package/dist/tools/retained-output.js.map +1 -0
  55. package/dist/types/agent/manager.d.ts +48 -0
  56. package/dist/types/agent/manager.d.ts.map +1 -1
  57. package/dist/types/agent/scheduler.d.ts +20 -0
  58. package/dist/types/agent/scheduler.d.ts.map +1 -1
  59. package/dist/types/agent/task.d.ts +2 -0
  60. package/dist/types/agent/task.d.ts.map +1 -1
  61. package/dist/types/session/records.d.ts +25 -0
  62. package/dist/types/session/records.d.ts.map +1 -1
  63. package/dist/types/session/records.js +16 -0
  64. package/dist/types/session/records.js.map +1 -1
  65. package/dist/types/tool/index.d.ts +8 -0
  66. package/dist/types/tool/index.d.ts.map +1 -1
  67. package/dist/types/tool/index.js.map +1 -1
  68. package/package.json +1 -1
  69. package/src/compaction/index.ts +6 -1
  70. package/src/compaction/manual.ts +15 -3
  71. package/src/compaction/summary.ts +17 -3
  72. package/src/compaction/tool-result-editing.ts +12 -9
  73. package/src/manager/agent/lifecycle.ts +181 -1
  74. package/src/public-runtime.ts +2 -1
  75. package/src/runtime/query/executor.ts +25 -1
  76. package/src/runtime/query/iteration/phases/compaction.ts +13 -2
  77. package/src/runtime/query/tool-output-budget.ts +65 -11
  78. package/src/scheduler/local.ts +14 -1
  79. package/src/store/evidence/disk.ts +28 -3
  80. package/src/store/evidence/source-text.ts +38 -2
  81. package/src/tools/builtins/read-file.ts +7 -2
  82. package/src/tools/retained-output.ts +38 -0
  83. package/src/types/agent/manager.ts +54 -0
  84. package/src/types/agent/scheduler.ts +21 -0
  85. package/src/types/agent/task.ts +2 -0
  86. package/src/types/session/records.ts +16 -0
  87. package/src/types/tool/index.ts +8 -0
@@ -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
@@ -42,6 +42,58 @@ export const MESSAGE_BUDGET_PREVIEW_CHARS = 2_000
42
42
  */
43
43
  export const SPILL_MARKER = 'The full output was written to:'
44
44
 
45
+ /**
46
+ * The recovery routes a host has mounted for a retained output. A pointer
47
+ * names only a tool that exists, so the same sentence is true wherever it is
48
+ * read: in the result, in the cleared placeholder that keeps it, and in a
49
+ * summary that quotes it.
50
+ */
51
+ export interface RetainedOutputRoutes {
52
+ /** The `read` tool is mounted and may open this session's own retained files. */
53
+ readonly read?: boolean
54
+ /** `search_conversation` and `read_conversation` are both mounted. */
55
+ readonly conversation?: boolean
56
+ }
57
+
58
+ /** Opening of the sentence that follows the spill line; how a pointer is recognised. */
59
+ export const RETAINED_OUTPUT_NOTE = 'This path identifies retained output, not the original input.'
60
+
61
+ const REPLAY_WARNING =
62
+ 'A fresh observation of the original input cannot recover its earlier contents. If recovery is unavailable, report the missing detail; do not replay the original action.'
63
+
64
+ /**
65
+ * The one sentence that says how to get at a retained output. Without routes
66
+ * (an embedder that told the budget nothing) it keeps the neutral wording.
67
+ */
68
+ export function describeRetainedOutputRecovery(routes?: RetainedOutputRoutes): string {
69
+ const read = routes?.read === true
70
+ const conversation = routes?.conversation === true
71
+ const how = [
72
+ read ? 'To read more of it, open this file with the read tool (offset and limit).' : '',
73
+ conversation
74
+ ? read
75
+ ? 'For anything else from earlier in this conversation, call search_conversation with a phrase from it, then read_conversation with the seq and part it returns.'
76
+ : 'To read more of it, call search_conversation with a phrase from it, then read_conversation with the seq and part it returns. The path is not a workspace file, so read and grep cannot open it.'
77
+ : '',
78
+ ].filter(Boolean)
79
+ if (how.length === 0)
80
+ return `${RETAINED_OUTPUT_NOTE} Use the host-authorized retained-output recovery tools when available; the path does not grant filesystem access. ${REPLAY_WARNING}`
81
+ return [RETAINED_OUTPUT_NOTE, ...how, REPLAY_WARNING].join(' ')
82
+ }
83
+
84
+ /**
85
+ * The spill line and the recovery sentence under it, when `text` carries them.
86
+ * Whatever shortens a result keeps this pair, so the route survives.
87
+ */
88
+ export function retainedOutputPointer(text: string): string | undefined {
89
+ // A tool may end its lines with \r\n or a bare \r; the pointer is one line either way.
90
+ const lines = text.split(/\r\n|\r|\n/)
91
+ const at = lines.findIndex((line) => line.startsWith(SPILL_MARKER))
92
+ if (at < 0) return undefined
93
+ const next = lines[at + 1]
94
+ return next?.startsWith(RETAINED_OUTPUT_NOTE) ? `${lines[at]}\n${next}` : (lines[at] as string)
95
+ }
96
+
45
97
  /**
46
98
  * Share of the budget spent on the head. The rest goes to the tail.
47
99
  *
@@ -91,7 +143,7 @@ function boundedPreview(
91
143
  '',
92
144
  ].join('\n')
93
145
  const compactMiddle = '\n[... omitted ...]\n'
94
- const spillLine = recovery.split('\n').find((line) => line.startsWith(SPILL_MARKER))
146
+ const spillLine = recovery.split(/\r\n|\r|\n/).find((line) => line.startsWith(SPILL_MARKER))
95
147
  const compactRecovery = spillLine ? `${compactMiddle}${spillLine}\n` : compactMiddle
96
148
  const middle =
97
149
  detailedMiddle.length < maxChars
@@ -182,6 +234,8 @@ export interface ApplyToolOutputBudgetOptions {
182
234
  * middle-elided instead — degraded, but never unbounded.
183
235
  */
184
236
  readonly spillDir?: string | undefined
237
+ /** Which recovery routes the host has mounted; see {@link describeRetainedOutputRecovery}. */
238
+ readonly recoveryRoutes?: RetainedOutputRoutes
185
239
  readonly onError?: (message: string) => void
186
240
  }
187
241
 
@@ -189,8 +243,9 @@ export interface ApplyToolOutputBudgetOptions {
189
243
  * Bound a tool result to the model-visible budget.
190
244
  *
191
245
  * Retention keeps the original while bounding its model-visible preview.
192
- * The host owns the recovery route and its permissions; a spill path is not
193
- * proof that workspace read/grep tools can access it. Middle-elision is the
246
+ * The recovery sentence names only the routes the host says are mounted
247
+ * ({@link RetainedOutputRoutes}); a spill path alone is not proof that a
248
+ * workspace tool can open it. Middle-elision is the
194
249
  * fallback for a turn with no directory to write to.
195
250
  *
196
251
  * The preview keeps head AND tail because the two ends carry different
@@ -224,10 +279,9 @@ export function applyToolOutputBudget(opts: ApplyToolOutputBudgetOptions): ToolO
224
279
 
225
280
  const spillPath = retained?.path
226
281
  const recovery = spillPath
227
- ? [
228
- `${SPILL_MARKER} ${spillPath}`,
229
- 'This path identifies retained output, not the original input. Use the host-authorized retained-output recovery tools when available; the path does not grant filesystem access. A fresh observation of the original input cannot recover its earlier contents. If recovery is unavailable, report the missing detail; do not replay the original action.',
230
- ].join('\n')
282
+ ? [`${SPILL_MARKER} ${spillPath}`, describeRetainedOutputRecovery(opts.recoveryRoutes)].join(
283
+ '\n',
284
+ )
231
285
  : 'The full output was not retained. Use a saved artifact or a read-only observation; do not repeat a state-changing action to recover its output.'
232
286
  const requestedPreview = opts.retainedPreviewChars
233
287
  // Separate the spill threshold from the cost of carrying its preview on every
@@ -358,11 +412,10 @@ export interface MessageBudgetReplacement {
358
412
  export interface FitResultsToMessageBudgetOptions {
359
413
  readonly maxChars: number
360
414
  readonly spillDir?: string | undefined
415
+ readonly recoveryRoutes?: RetainedOutputRoutes
361
416
  readonly onError?: (message: string) => void
362
417
  }
363
418
 
364
- const SPILL_LINE = new RegExp(`^${SPILL_MARKER} .+$`, 'm')
365
-
366
419
  /**
367
420
  * Hold one assistant message's combined tool results under a cap.
368
421
  *
@@ -409,7 +462,7 @@ function previewForMessageBudget(
409
462
  item: MessageBudgetItem,
410
463
  opts: FitResultsToMessageBudgetOptions,
411
464
  ): MessageBudgetReplacement {
412
- const spilledAt = SPILL_LINE.exec(item.output)?.[0]
465
+ const spilledAt = retainedOutputPointer(item.output)
413
466
  if (spilledAt !== undefined) {
414
467
  const output = [
415
468
  safeHead(item.output, MESSAGE_BUDGET_PREVIEW_CHARS / 2),
@@ -420,7 +473,7 @@ function previewForMessageBudget(
420
473
  return {
421
474
  output,
422
475
  originalLength: item.output.length,
423
- spillPath: spilledAt.slice(SPILL_MARKER.length + 1),
476
+ spillPath: (spilledAt.split('\n')[0] as string).slice(SPILL_MARKER.length + 1),
424
477
  }
425
478
  }
426
479
  const budgeted = applyToolOutputBudget({
@@ -430,6 +483,7 @@ function previewForMessageBudget(
430
483
  output: item.output,
431
484
  maxChars: MESSAGE_BUDGET_PREVIEW_CHARS,
432
485
  spillDir: opts.spillDir,
486
+ ...(opts.recoveryRoutes ? { recoveryRoutes: opts.recoveryRoutes } : {}),
433
487
  ...(opts.onError ? { onError: opts.onError } : {}),
434
488
  })
435
489
  return {
@@ -1,7 +1,11 @@
1
1
  import { GENAI } from '../constants/telemetry/index.js'
2
2
  import { taskFailed } from '../tools/coordinator/outcome.js'
3
3
  import type { AgentInput } from '../types/agent/base.js'
4
- import type { AgentManagerContract } from '../types/agent/manager.js'
4
+ import type {
5
+ AgentManagerContract,
6
+ RestoreChildOptions,
7
+ RestoreChildResult,
8
+ } from '../types/agent/manager.js'
5
9
  import type {
6
10
  CreateTaskOptions,
7
11
  SiblingFailurePolicy,
@@ -113,6 +117,9 @@ export class LocalTaskScheduler implements TaskScheduler {
113
117
  {
114
118
  agentId: options.agentId,
115
119
  ...(options.resumeSessionId ? { resumeSessionId: options.resumeSessionId } : {}),
120
+ ...(options.continuationProfile
121
+ ? { continuationProfile: options.continuationProfile }
122
+ : {}),
116
123
  beforeStart: options.beforeStart,
117
124
  ...(options.workspace ? { workspace: options.workspace } : {}),
118
125
  ...(options.planId ? { planId: options.planId } : {}),
@@ -363,6 +370,12 @@ export class LocalTaskScheduler implements TaskScheduler {
363
370
  await this.agentManager.continueTask(taskId, message)
364
371
  }
365
372
 
373
+ async restoreChild(options: RestoreChildOptions): Promise<RestoreChildResult> {
374
+ if (!this.agentManager.restoreChildConversation)
375
+ return { status: 'refused', reason: 'no-layout' }
376
+ return this.agentManager.restoreChildConversation(options, this.taskContext)
377
+ }
378
+
366
379
  cancelTask(taskId: TaskId, cause?: CancelCause): void {
367
380
  this.agentManager.cancel(taskId, cause)
368
381
  }
@@ -110,8 +110,9 @@ export interface EvidenceAnchor {
110
110
  * session log, or one turn of it. Closed by default (no active turn in scope);
111
111
  * snapshot mode also reads a session whose turn is running, without assuming
112
112
  * its writer is dead. The host owns authorization and the log's directory.
113
- * Changes to the log invalidate addresses; record and chunk digests detect
114
- * changed bytes. This is not a hostile filesystem sandbox.
113
+ * An address names a record by offset, length and digest and survives appends
114
+ * to the log; a replaced or rewritten log is refused and record and chunk
115
+ * digests detect changed bytes. This is not a hostile filesystem sandbox.
115
116
  */
116
117
  export function createSessionTextEvidenceSource(
117
118
  options: SessionEvidenceSourceOptions,
@@ -261,9 +262,18 @@ function createSource(
261
262
  if (!state.closed && consistency === 'closed') {
262
263
  throw new Error('Evidence scope has an active turn; it is not closed.')
263
264
  }
265
+ // The anchored prefix is verified here and again after the action. It is not
266
+ // part of the seal: the anchor moves with every record the writer appends, so
267
+ // binding it would void an address the moment the search call that returned
268
+ // it is itself recorded.
264
269
  const anchorKey = anchor ? await verifyAnchor(handle, anchor) : undefined
270
+ // A session log only grows. An address names one record by offset, length and
271
+ // digest, and a read verifies all three against the bytes, so what must
272
+ // bind a seal is the file's identity, never its size or times: those change
273
+ // with every append and would void every address and cursor handed out a
274
+ // moment earlier, including one a search just returned.
265
275
  const sourceKey = digest(
266
- `session-v1:${consistency}:${mode}:${scopeKey}:${anchorKey ?? stamp(before)}`,
276
+ `session-v1:${consistency}:${mode}:${scopeKey}:${fileIdentity(before)}`,
267
277
  )
268
278
  const seal = evidenceSeal(state.first, sourceKey)
269
279
  const value = await action(handle, size, seal, sourceKey, budget, !state.closed)
@@ -552,6 +562,21 @@ function createSource(
552
562
  })
553
563
  }
554
564
 
565
+ /**
566
+ * What names the log file across appends: device and inode, plus the creation
567
+ * time where the filesystem reports no inode (zero), so a replaced file still
568
+ * changes the identity there.
569
+ */
570
+ function fileIdentity(stats: {
571
+ dev: number | bigint
572
+ ino: number | bigint
573
+ birthtimeMs: number
574
+ }): string {
575
+ return Number(stats.ino) === 0
576
+ ? `${stats.dev}:0:${stats.birthtimeMs}`
577
+ : `${stats.dev}:${stats.ino}`
578
+ }
579
+
555
580
  /** The anchor record must still hold the bytes the writer vouched for. */
556
581
  async function verifyAnchor(handle: FileHandle, anchor: EvidenceAnchor): Promise<string> {
557
582
  const bytes = await readBytes(handle, anchor.offset, anchor.length, {
@@ -235,6 +235,28 @@ async function sourceText(
235
235
  }
236
236
  }
237
237
 
238
+ /**
239
+ * @experimental A read named a byte offset the retained text cannot start at.
240
+ * `inside_character` means the offset falls inside a multi-byte character (the
241
+ * character starts up to three bytes earlier); `past_end` means it lies beyond
242
+ * the text. Every other read failure (integrity, ownership, a changed source)
243
+ * is a plain `Error`, so a caller can recover from a bad position without
244
+ * treating a refusal as one.
245
+ */
246
+ export class SessionEvidenceOffsetError extends Error {
247
+ constructor(
248
+ readonly code: 'inside_character' | 'past_end',
249
+ message: string,
250
+ /** Bytes of the retained text. */
251
+ readonly totalBytes: number,
252
+ /** Bytes this failed read was charged. */
253
+ readonly scannedBytes: number,
254
+ ) {
255
+ super(message)
256
+ this.name = 'SessionEvidenceOffsetError'
257
+ }
258
+ }
259
+
238
260
  export async function readTextPage(
239
261
  source: TextSource,
240
262
  scope: SessionEvidenceScope,
@@ -242,13 +264,27 @@ export async function readTextPage(
242
264
  budget: EvidenceBudget,
243
265
  ): Promise<SessionTextEvidenceReadResult> {
244
266
  const entry = source.entry
245
- if (offset > source.bytes) throw new Error('Offset exceeds retained text.')
267
+ if (offset > source.bytes)
268
+ throw new SessionEvidenceOffsetError(
269
+ 'past_end',
270
+ 'Offset exceeds retained text.',
271
+ source.bytes,
272
+ budget.bytes,
273
+ )
246
274
  const chunk = entry.spill
247
275
  ? Math.min(Math.floor(offset / EVIDENCE_CHUNK_BYTES), source.chunks - 1)
248
276
  : 0
249
277
  const window = await source.window(chunk)
250
- if (offset < window.offset) throw new Error('Offset splits a UTF-8 character.')
278
+ const splits = () =>
279
+ new SessionEvidenceOffsetError(
280
+ 'inside_character',
281
+ 'Offset splits a UTF-8 character.',
282
+ source.bytes,
283
+ budget.bytes,
284
+ )
285
+ if (offset < window.offset) throw splits()
251
286
  const bytes = window.bytes.subarray(offset - window.offset)
287
+ if (bytes.length && ((bytes[0] ?? 0) & 0xc0) === 0x80) throw splits()
252
288
  let text = decode(utf8Page(bytes, 24_000))
253
289
  if (text.length > 6000) {
254
290
  let end = 6000
@@ -5,6 +5,7 @@ import type { ToolContext } from '../../types/tool/index.js'
5
5
  import type { ToolFacts } from '../../types/tool/presentation.js'
6
6
  import { defineTool } from '../defineTool.js'
7
7
  import { resolveWithinAnyReal, toolRoots } from '../paths.js'
8
+ import { resolveRetainedOutput } from '../retained-output.js'
8
9
  import { looksBinary } from '../text-window.js'
9
10
  import { fingerprintContent } from './content-fingerprint.js'
10
11
  import { type RenderedRead, renderCappedRead } from './read-render.js'
@@ -71,8 +72,12 @@ export const ReadFileTool = defineTool({
71
72
  },
72
73
 
73
74
  async execute(input, context) {
75
+ // This session's own retained tool output lives on the host, outside the
76
+ // roots and outside any sandbox. It is read here, as a plain window of text,
77
+ // because the result that named it told the model to.
78
+ const retained = await resolveRetainedOutput(context.retainedOutputDirectory, input.path)
74
79
  // Sandbox-aware: route through sandbox.readFile() when available
75
- if (context.sandbox) {
80
+ if (context.sandbox && retained === undefined) {
76
81
  const buffer = await context.sandbox.readFile(input.path)
77
82
  const binaryGuidance = describeStructuredBinaryRead(input.path, buffer)
78
83
  if (binaryGuidance) {
@@ -112,7 +117,7 @@ export const ReadFileTool = defineTool({
112
117
  }
113
118
  }
114
119
 
115
- const filePath = await resolveWithinAnyReal(toolRoots(context), input.path)
120
+ const filePath = retained ?? (await resolveWithinAnyReal(toolRoots(context), input.path))
116
121
  // Refused on the size alone, before the file is in memory, when no window
117
122
  // was named: the cap exists so a huge file is never loaded to be refused.
118
123
  if (input.readRange === undefined && input.limit === undefined) {