@namzu/sdk 37.0.0 → 38.0.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 (180) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/dist/bridge/a2a/mapper.d.ts.map +1 -1
  3. package/dist/bridge/a2a/mapper.js +2 -0
  4. package/dist/bridge/a2a/mapper.js.map +1 -1
  5. package/dist/bridge/sse/mapper.d.ts.map +1 -1
  6. package/dist/bridge/sse/mapper.js +14 -1
  7. package/dist/bridge/sse/mapper.js.map +1 -1
  8. package/dist/config/runtime.d.ts +16 -6
  9. package/dist/config/runtime.d.ts.map +1 -1
  10. package/dist/config/runtime.js +2 -0
  11. package/dist/config/runtime.js.map +1 -1
  12. package/dist/contracts/api.d.ts +1 -1
  13. package/dist/contracts/api.d.ts.map +1 -1
  14. package/dist/manager/agent/lifecycle.d.ts.map +1 -1
  15. package/dist/manager/agent/lifecycle.js +10 -9
  16. package/dist/manager/agent/lifecycle.js.map +1 -1
  17. package/dist/prompt/coding-agent-doctrine.d.ts +2 -2
  18. package/dist/prompt/coding-agent-doctrine.d.ts.map +1 -1
  19. package/dist/prompt/coding-agent-doctrine.js +11 -5
  20. package/dist/prompt/coding-agent-doctrine.js.map +1 -1
  21. package/dist/provider/capabilities.d.ts +2 -0
  22. package/dist/provider/capabilities.d.ts.map +1 -1
  23. package/dist/provider/capabilities.js +11 -0
  24. package/dist/provider/capabilities.js.map +1 -1
  25. package/dist/provider/fallback.d.ts.map +1 -1
  26. package/dist/provider/fallback.js +5 -3
  27. package/dist/provider/fallback.js.map +1 -1
  28. package/dist/public-runtime.d.ts +1 -0
  29. package/dist/public-runtime.d.ts.map +1 -1
  30. package/dist/public-runtime.js +1 -0
  31. package/dist/public-runtime.js.map +1 -1
  32. package/dist/public-tools.d.ts +1 -0
  33. package/dist/public-tools.d.ts.map +1 -1
  34. package/dist/public-tools.js +1 -0
  35. package/dist/public-tools.js.map +1 -1
  36. package/dist/public-types.d.ts +1 -0
  37. package/dist/public-types.d.ts.map +1 -1
  38. package/dist/run/reporter.d.ts.map +1 -1
  39. package/dist/run/reporter.js +1 -0
  40. package/dist/run/reporter.js.map +1 -1
  41. package/dist/run/token-budget.d.ts +10 -4
  42. package/dist/run/token-budget.d.ts.map +1 -1
  43. package/dist/run/token-budget.js +45 -14
  44. package/dist/run/token-budget.js.map +1 -1
  45. package/dist/runtime/query/executor.d.ts +3 -3
  46. package/dist/runtime/query/executor.d.ts.map +1 -1
  47. package/dist/runtime/query/executor.js +24 -16
  48. package/dist/runtime/query/executor.js.map +1 -1
  49. package/dist/runtime/query/index.d.ts +2 -0
  50. package/dist/runtime/query/index.d.ts.map +1 -1
  51. package/dist/runtime/query/index.js +1 -0
  52. package/dist/runtime/query/index.js.map +1 -1
  53. package/dist/runtime/query/iteration/index.d.ts +2 -0
  54. package/dist/runtime/query/iteration/index.d.ts.map +1 -1
  55. package/dist/runtime/query/iteration/index.js +23 -2
  56. package/dist/runtime/query/iteration/index.js.map +1 -1
  57. package/dist/runtime/query/iteration/stream-turn.d.ts.map +1 -1
  58. package/dist/runtime/query/iteration/stream-turn.js +11 -1
  59. package/dist/runtime/query/iteration/stream-turn.js.map +1 -1
  60. package/dist/runtime/query/observation-context.d.ts +10 -0
  61. package/dist/runtime/query/observation-context.d.ts.map +1 -0
  62. package/dist/runtime/query/observation-context.js +84 -0
  63. package/dist/runtime/query/observation-context.js.map +1 -0
  64. package/dist/runtime/query/request-context.d.ts +30 -0
  65. package/dist/runtime/query/request-context.d.ts.map +1 -0
  66. package/dist/runtime/query/request-context.js +76 -0
  67. package/dist/runtime/query/request-context.js.map +1 -0
  68. package/dist/runtime/query/request-rich-content.d.ts.map +1 -1
  69. package/dist/runtime/query/request-rich-content.js +6 -3
  70. package/dist/runtime/query/request-rich-content.js.map +1 -1
  71. package/dist/runtime/query/review-policy.d.ts +3 -0
  72. package/dist/runtime/query/review-policy.d.ts.map +1 -1
  73. package/dist/runtime/query/review-policy.js +4 -1
  74. package/dist/runtime/query/review-policy.js.map +1 -1
  75. package/dist/runtime/query/tooling.d.ts +1 -0
  76. package/dist/runtime/query/tooling.d.ts.map +1 -1
  77. package/dist/runtime/query/tooling.js +1 -0
  78. package/dist/runtime/query/tooling.js.map +1 -1
  79. package/dist/sandbox/file-walk-program.d.ts.map +1 -1
  80. package/dist/sandbox/file-walk-program.js +8 -0
  81. package/dist/sandbox/file-walk-program.js.map +1 -1
  82. package/dist/sandbox/file-walk.d.ts.map +1 -1
  83. package/dist/sandbox/file-walk.js +9 -0
  84. package/dist/sandbox/file-walk.js.map +1 -1
  85. package/dist/scheduler/completion-inbox.d.ts.map +1 -1
  86. package/dist/scheduler/completion-inbox.js +3 -0
  87. package/dist/scheduler/completion-inbox.js.map +1 -1
  88. package/dist/store/run/token-budget-disk.js +3 -0
  89. package/dist/store/run/token-budget-disk.js.map +1 -1
  90. package/dist/tools/builtins/edit.d.ts.map +1 -1
  91. package/dist/tools/builtins/edit.js +12 -26
  92. package/dist/tools/builtins/edit.js.map +1 -1
  93. package/dist/tools/builtins/file-write-result.d.ts +4 -0
  94. package/dist/tools/builtins/file-write-result.d.ts.map +1 -0
  95. package/dist/tools/builtins/file-write-result.js +72 -0
  96. package/dist/tools/builtins/file-write-result.js.map +1 -0
  97. package/dist/tools/builtins/glob.d.ts.map +1 -1
  98. package/dist/tools/builtins/glob.js +8 -1
  99. package/dist/tools/builtins/glob.js.map +1 -1
  100. package/dist/tools/builtins/grep.d.ts.map +1 -1
  101. package/dist/tools/builtins/grep.js +10 -0
  102. package/dist/tools/builtins/grep.js.map +1 -1
  103. package/dist/tools/builtins/job.d.ts.map +1 -1
  104. package/dist/tools/builtins/job.js +6 -0
  105. package/dist/tools/builtins/job.js.map +1 -1
  106. package/dist/tools/builtins/read-file.d.ts.map +1 -1
  107. package/dist/tools/builtins/read-file.js +10 -0
  108. package/dist/tools/builtins/read-file.js.map +1 -1
  109. package/dist/tools/builtins/write-file.d.ts.map +1 -1
  110. package/dist/tools/builtins/write-file.js +30 -44
  111. package/dist/tools/builtins/write-file.js.map +1 -1
  112. package/dist/tools/file-read-tracker.d.ts +4 -0
  113. package/dist/tools/file-read-tracker.d.ts.map +1 -0
  114. package/dist/tools/file-read-tracker.js +16 -0
  115. package/dist/tools/file-read-tracker.js.map +1 -0
  116. package/dist/tools/task/list.js +2 -2
  117. package/dist/tools/task/list.js.map +1 -1
  118. package/dist/types/plugin/index.d.ts +7 -0
  119. package/dist/types/plugin/index.d.ts.map +1 -1
  120. package/dist/types/plugin/index.js.map +1 -1
  121. package/dist/types/provider/chat.d.ts +4 -0
  122. package/dist/types/provider/chat.d.ts.map +1 -1
  123. package/dist/types/provider/config.d.ts +2 -0
  124. package/dist/types/provider/config.d.ts.map +1 -1
  125. package/dist/types/provider/stream.d.ts +6 -0
  126. package/dist/types/provider/stream.d.ts.map +1 -1
  127. package/dist/types/run/config.d.ts +2 -0
  128. package/dist/types/run/config.d.ts.map +1 -1
  129. package/dist/types/run/events.d.ts +10 -0
  130. package/dist/types/run/events.d.ts.map +1 -1
  131. package/dist/types/run/events.js.map +1 -1
  132. package/dist/types/sandbox/index.d.ts +1 -0
  133. package/dist/types/sandbox/index.d.ts.map +1 -1
  134. package/dist/types/sandbox/index.js.map +1 -1
  135. package/dist/types/tool/presentation.d.ts +4 -0
  136. package/dist/types/tool/presentation.d.ts.map +1 -1
  137. package/package.json +1 -1
  138. package/src/bridge/a2a/mapper.ts +2 -0
  139. package/src/bridge/sse/mapper.ts +14 -1
  140. package/src/config/runtime.ts +2 -0
  141. package/src/contracts/api.ts +1 -0
  142. package/src/manager/agent/lifecycle.ts +12 -9
  143. package/src/prompt/coding-agent-doctrine.ts +11 -5
  144. package/src/provider/capabilities.ts +16 -0
  145. package/src/provider/fallback.ts +8 -3
  146. package/src/public-runtime.ts +2 -0
  147. package/src/public-tools.ts +2 -0
  148. package/src/public-types.ts +6 -0
  149. package/src/run/reporter.ts +1 -0
  150. package/src/run/token-budget.ts +51 -14
  151. package/src/runtime/query/executor.ts +23 -15
  152. package/src/runtime/query/index.ts +3 -0
  153. package/src/runtime/query/iteration/index.ts +28 -2
  154. package/src/runtime/query/iteration/stream-turn.ts +14 -1
  155. package/src/runtime/query/observation-context.ts +86 -0
  156. package/src/runtime/query/request-context.ts +103 -0
  157. package/src/runtime/query/request-rich-content.ts +8 -3
  158. package/src/runtime/query/review-policy.ts +7 -1
  159. package/src/runtime/query/tooling.ts +2 -0
  160. package/src/sandbox/file-walk-program.ts +8 -0
  161. package/src/sandbox/file-walk.ts +9 -0
  162. package/src/scheduler/completion-inbox.ts +3 -0
  163. package/src/store/run/token-budget-disk.ts +3 -0
  164. package/src/tools/builtins/edit.ts +12 -27
  165. package/src/tools/builtins/file-write-result.ts +84 -0
  166. package/src/tools/builtins/glob.ts +7 -1
  167. package/src/tools/builtins/grep.ts +10 -0
  168. package/src/tools/builtins/job.ts +8 -0
  169. package/src/tools/builtins/read-file.ts +10 -0
  170. package/src/tools/builtins/write-file.ts +39 -44
  171. package/src/tools/file-read-tracker.ts +16 -0
  172. package/src/tools/task/list.ts +2 -2
  173. package/src/types/plugin/index.ts +10 -0
  174. package/src/types/provider/chat.ts +2 -0
  175. package/src/types/provider/config.ts +2 -0
  176. package/src/types/provider/stream.ts +7 -0
  177. package/src/types/run/config.ts +2 -0
  178. package/src/types/run/events.ts +10 -0
  179. package/src/types/sandbox/index.ts +1 -0
  180. package/src/types/tool/presentation.ts +4 -0
@@ -21,6 +21,8 @@ export interface TokenBudgetRequestSnapshot {
21
21
  runId: RunId
22
22
  /** Cumulative request usage already charged to the owning run. */
23
23
  usage?: TokenUsage
24
+ /** No reliable final receipt; only explicit reconciliation clears this marker. */
25
+ unresolved?: boolean
24
26
  }
25
27
 
26
28
  /** Durable authority independent of a message checkpoint's cadence. */
@@ -49,8 +51,10 @@ export interface TokenBudgetSummary {
49
51
  remainingTokens: number | null
50
52
  inFlightRequests: number
51
53
  unsettledChildren: number
52
- /** An unresolved provider call or failed ledger write prevents admission. */
54
+ /** This account is blocked by accounting failure or uncertain finite allowance. */
53
55
  poisoned: boolean
56
+ /** Unresolved receipts in this subtree, including retained completion records. */
57
+ unresolvedRequests?: number
54
58
  }
55
59
 
56
60
  interface SharedLedger {
@@ -150,9 +154,6 @@ export function validateTokenBudgetSnapshot(value: unknown): TokenBudgetSnapshot
150
154
  const accountId = id(account.id, 'account id')
151
155
  if (typeof account.settled !== 'boolean') throw new Error('Invalid token budget settled flag')
152
156
  const limit = count(account.limit, 'limit')
153
- if (accountId !== rootAccountId && limit === 0) {
154
- throw new Error('A child token budget must have a positive limit')
155
- }
156
157
  const usage: Record<string, TokenUsage> = {}
157
158
  for (const [runId, rawUsage] of Object.entries(object(account.usage, 'account usage'))) {
158
159
  id(runId, 'usage run id')
@@ -198,6 +199,8 @@ export function validateTokenBudgetSnapshot(value: unknown): TokenBudgetSnapshot
198
199
  const request = object(raw, 'request')
199
200
  const requestId = id(request.id, 'request id')
200
201
  const accountId = id(request.accountId, 'request account id')
202
+ if (request.unresolved !== undefined && typeof request.unresolved !== 'boolean')
203
+ throw new Error('Invalid unresolved request marker')
201
204
  if (!isEntityId(request.runId, 'run')) throw new Error('Invalid token budget request run id')
202
205
  if (requestIds.has(requestId)) throw new Error('Duplicate token budget request')
203
206
  requestIds.add(requestId)
@@ -209,6 +212,7 @@ export function validateTokenBudgetSnapshot(value: unknown): TokenBudgetSnapshot
209
212
  accountId,
210
213
  runId: request.runId,
211
214
  ...(request.usage === undefined ? {} : { usage: usageValue(request.usage) }),
215
+ ...(request.unresolved === true ? { unresolved: true } : {}),
212
216
  }
213
217
  }
214
218
  const requests = source.requests.map(parseRequest)
@@ -346,9 +350,11 @@ export class TokenBudget {
346
350
  rootAccountId: state.rootAccountId,
347
351
  rootRunId: state.rootRunId,
348
352
  accounts: new Map(state.accounts.map((account) => [account.id, account])),
349
- requests: new Map(state.requests.map((request) => [request.id, request])),
353
+ requests: new Map(
354
+ state.requests.map((request) => [request.id, { ...request, unresolved: true }]),
355
+ ),
350
356
  finishedRequests: new Map(state.completedRequests.map((request) => [request.id, request])),
351
- poisoned: state.poisoned === true || state.requests.length > 0,
357
+ poisoned: state.poisoned === true,
352
358
  persistence,
353
359
  writes: Promise.resolve(),
354
360
  },
@@ -384,7 +390,7 @@ export class TokenBudget {
384
390
  }
385
391
 
386
392
  get remaining(): number {
387
- if (this.ledger.poisoned || this.node.settled) return 0
393
+ if (this.admissionBlocked() || this.node.settled) return 0
388
394
  let cursor = this.node
389
395
  let available = this.free(cursor)
390
396
  while (cursor.parentId !== undefined) {
@@ -430,7 +436,7 @@ export class TokenBudget {
430
436
  reserve(tokens: number): TokenBudget {
431
437
  count(tokens, 'reservation')
432
438
  if (this.hasRequest(this.node)) throw new Error('Token budget has an in-flight request')
433
- if (tokens === 0 || tokens > this.remaining)
439
+ if ((tokens === 0 && this.remaining !== Number.POSITIVE_INFINITY) || tokens > this.remaining)
434
440
  throw new Error('Token budget cannot reserve the requested tokens')
435
441
  count(this.totals(this.node).reserved + tokens, 'reserved allowance')
436
442
  const accountId = randomUUID()
@@ -468,7 +474,8 @@ export class TokenBudget {
468
474
  }
469
475
 
470
476
  async beginRequest(): Promise<string> {
471
- if (this.ledger.poisoned) throw new Error('Token budget has unresolved spend')
477
+ if (this.admissionBlocked())
478
+ throw new Error('Token budget has unresolved spend or an accounting failure')
472
479
  if (this.node.settled) throw new Error('Token budget is settled')
473
480
  if (this.remaining <= 0) throw new Error('Token budget is exhausted')
474
481
  const runId = this.runId
@@ -504,10 +511,11 @@ export class TokenBudget {
504
511
  async failRequest(requestId: string, usage?: TokenUsage): Promise<void> {
505
512
  if (this.hasReceipt(requestId)) return this.flush()
506
513
  const request = this.requireRequest(requestId)
507
- this.ledger.poisoned = true
514
+ request.unresolved = true
508
515
  try {
509
516
  if (usage !== undefined) this.recordRequestUsage(request, usage)
510
517
  } catch (error) {
518
+ this.ledger.poisoned = true
511
519
  this.persist()
512
520
  await this.flush()
513
521
  throw error
@@ -518,9 +526,9 @@ export class TokenBudget {
518
526
 
519
527
  /**
520
528
  * Explicit host recovery after obtaining the provider's final usage receipt.
521
- * Ordinary completion never reopens an uncertain ledger. Reconciliation can
522
- * do so only after every outstanding request has a measured receipt and all
523
- * accounting writes succeeded; it never resets usage or settled accounts.
529
+ * Ordinary completion never clears uncertainty. Reconciliation resolves this
530
+ * request after its receipt is persisted; other uncertain requests continue
531
+ * to constrain their shared finite allowances. Usage and settlement are retained.
524
532
  */
525
533
  async reconcileRequest(requestId: string, usage: TokenUsage): Promise<void> {
526
534
  const measured = usageValue(usage)
@@ -536,6 +544,10 @@ export class TokenBudget {
536
544
  } else {
537
545
  await this.finishRequest(requestId, measured)
538
546
  }
547
+ const receipt = this.ledger.finishedRequests.get(requestId)
548
+ if (receipt) receipt.unresolved = false
549
+ this.persist()
550
+ await this.flush()
539
551
  if (this.ledger.requests.size === 0 && this.ledger.persistenceError === undefined) {
540
552
  this.ledger.poisoned = false
541
553
  this.persist()
@@ -574,7 +586,10 @@ export class TokenBudget {
574
586
  ).length,
575
587
  unsettledChildren: this.children(this.node).filter((child) => !this.totals(child).closed)
576
588
  .length,
577
- poisoned: this.ledger.poisoned,
589
+ poisoned: this.admissionBlocked(),
590
+ unresolvedRequests: this.uncertainRequests().filter((request) =>
591
+ descendants.has(request.accountId),
592
+ ).length,
578
593
  }
579
594
  }
580
595
 
@@ -595,6 +610,28 @@ export class TokenBudget {
595
610
  if (this.ledger.persistenceError !== undefined) throw this.ledger.persistenceError
596
611
  }
597
612
 
613
+ private uncertainRequests(): TokenBudgetRequestSnapshot[] {
614
+ return [...this.ledger.requests.values(), ...this.ledger.finishedRequests.values()].filter(
615
+ (request) => request.unresolved === true,
616
+ )
617
+ }
618
+ private admissionBlocked(): boolean {
619
+ if (this.ledger.poisoned || this.ledger.persistenceError !== undefined) return true
620
+ const uncertain = this.uncertainRequests()
621
+ if (uncertain.length === 0) return false
622
+ // An account cannot resume its own uncertain request, even without a cap.
623
+ if (uncertain.some((request) => request.accountId === this.accountId)) return true
624
+ let cursor = this.node
625
+ for (;;) {
626
+ if (cursor.limit > 0) {
627
+ const descendants = this.descendants(cursor)
628
+ if (uncertain.some((request) => descendants.has(request.accountId))) return true
629
+ }
630
+ if (cursor.parentId === undefined) return false
631
+ cursor = this.requireAccount(cursor.parentId)
632
+ }
633
+ }
634
+
598
635
  private get node(): TokenBudgetAccountSnapshot {
599
636
  return this.requireAccount(this.accountId)
600
637
  }
@@ -11,8 +11,8 @@ import { probe as defaultProbeRegistry } from '../../probe/registry.js'
11
11
  import type { ProbeEnforcement } from '../../probe/registry.js'
12
12
  import { renderToolSchema } from '../../registry/tool/schema.js'
13
13
  import type { ActivityStore } from '../../store/activity/memory.js'
14
- import { fingerprintContent } from '../../tools/builtins/content-fingerprint.js'
15
14
  import { SKILL_TOOL_NAME } from '../../tools/builtins/skill.js'
15
+ import { createFileReadTracker } from '../../tools/file-read-tracker.js'
16
16
  import type { RunId, ToolUseId } from '../../types/ids/index.js'
17
17
  import type { InvocationState } from '../../types/invocation/index.js'
18
18
  import {
@@ -290,6 +290,7 @@ function parseArguments(raw: string): unknown {
290
290
  }
291
291
 
292
292
  export interface ToolExecutorConfig {
293
+ fileReadTracker?: FileReadTracker
293
294
  tools: ToolRegistryContract
294
295
  runId: RunId
295
296
  workingDirectory: string
@@ -501,20 +502,7 @@ export class ToolExecutor {
501
502
  private readonly preparedBatches = new WeakSet<PreparedToolBatch>()
502
503
  /** Set per turn by the orchestrator; see {@link setStepAllowedTools}. */
503
504
  private stepAllowedTools?: readonly string[]
504
- private readonly readPaths: Set<string> = new Set()
505
- private readonly readFingerprints: Map<string, string> = new Map()
506
- private readonly fileReadTracker: FileReadTracker = {
507
- recordRead: (key: string, content?: string) => {
508
- this.readPaths.add(key)
509
- // Only when the reader had the body. A tool that records a read
510
- // without one leaves the previous fingerprint alone rather than
511
- // clearing it, so a later write is still checked against the last
512
- // body anyone actually saw.
513
- if (content !== undefined) this.readFingerprints.set(key, fingerprintContent(content))
514
- },
515
- hasRead: (key: string) => this.readPaths.has(key),
516
- fingerprint: (key: string) => this.readFingerprints.get(key),
517
- }
505
+ private readonly fileReadTracker: FileReadTracker
518
506
 
519
507
  constructor(
520
508
  config: ToolExecutorConfig,
@@ -532,6 +520,7 @@ export class ToolExecutor {
532
520
  config.readToolCallBudgetEvents,
533
521
  )
534
522
  }
523
+ this.fileReadTracker = config.fileReadTracker ?? createFileReadTracker()
535
524
  this.config = config
536
525
  this.activityStore = activityStore
537
526
  this.emitEvent = emitEvent
@@ -1211,6 +1200,7 @@ export class ToolExecutor {
1211
1200
  toolUseId: nestedId,
1212
1201
  toolName: name,
1213
1202
  result: budgeted.output,
1203
+ ...(!budgeted.truncated ? this.resultPresentation(name, preparedInput, result) : {}),
1214
1204
  isError: !result.success,
1215
1205
  durationMs: Date.now() - startedAt,
1216
1206
  outputLength: budgeted.originalLength,
@@ -1230,6 +1220,21 @@ export class ToolExecutor {
1230
1220
  return visibleResult
1231
1221
  }
1232
1222
 
1223
+ private resultPresentation(name: string, input: unknown, result: ToolResult) {
1224
+ if (!result.success) return {}
1225
+ try {
1226
+ const view = this.config.tools.get(name)?.presentResult?.(input, result)
1227
+ if (view?.kind !== 'diff') return {}
1228
+ const serialized = JSON.stringify(view)
1229
+ if (serialized.length > (this.config.maxToolOutputChars ?? DEFAULT_MAX_TOOL_OUTPUT_CHARS))
1230
+ return {}
1231
+ return { presentation: view }
1232
+ } catch {
1233
+ // A presentation hook must not fail an already completed mutation.
1234
+ return {}
1235
+ }
1236
+ }
1237
+
1233
1238
  private buildToolContext(
1234
1239
  recordObservation: (observation: ToolResultObservation) => void = () => {},
1235
1240
  ): ToolContext {
@@ -1721,6 +1726,9 @@ export class ToolExecutor {
1721
1726
  toolUseId: toolCall.id,
1722
1727
  toolName,
1723
1728
  result: output,
1729
+ ...(!postOverride && !budgeted.truncated && rawOutput === output
1730
+ ? this.resultPresentation(toolName, input, result)
1731
+ : {}),
1724
1732
  isError: effectiveIsError,
1725
1733
  durationMs,
1726
1734
  // Pre-truncation size, so a host can show "returned 2.1 MB" even
@@ -157,6 +157,8 @@ import { createToolPause } from './tool-pause.js'
157
157
  import { ToolingBootstrap } from './tooling.js'
158
158
 
159
159
  export interface QueryParams {
160
+ /** Share observations across turns of one conversation and filesystem; otherwise run-local. */
161
+ fileReadTracker?: import('../../types/tool/index.js').FileReadTracker
160
162
  /** One account shared with the task scheduler and descendant runs. */
161
163
  budget?: TokenBudget
162
164
  /** Canonical tree ledger; defaults to disk beside the root run. */
@@ -1731,6 +1733,7 @@ export async function* query(params: QueryParams): AsyncGenerator<RunEvent, Run>
1731
1733
  // registry would answer about whatever the last run configured.
1732
1734
  ...(params.skillRegistry ? { skills: params.skillRegistry } : {}),
1733
1735
  ...(params.web ? { web: params.web } : {}),
1736
+ ...(params.fileReadTracker ? { fileReadTracker: params.fileReadTracker } : {}),
1734
1737
  ...(params.toolTimeoutMs !== undefined ? { toolTimeoutMs: params.toolTimeoutMs } : {}),
1735
1738
  ...(params.toolRetryBackoff !== undefined
1736
1739
  ? { toolRetryBackoff: params.toolRetryBackoff }
@@ -54,7 +54,13 @@ import { toErrorMessage } from '../../../utils/error.js'
54
54
  import { stableDigest } from '../../../utils/hash.js'
55
55
  import { generateMessageId } from '../../../utils/id.js'
56
56
  import type { ToolCallOutcome } from '../executor.js'
57
+ import { projectObservationContext } from '../observation-context.js'
57
58
  import { applyLifecycleHookResults } from '../plugin-hooks.js'
59
+ import {
60
+ type RequestContextSnapshot,
61
+ diffRequestContext,
62
+ snapshotRequestContext,
63
+ } from '../request-context.js'
58
64
  import {
59
65
  DEFAULT_MAX_REQUEST_RICH_CONTENT_BYTES,
60
66
  type RequestImageIdentity,
@@ -164,6 +170,14 @@ export class IterationOrchestrator {
164
170
  * one process must not suppress each other's first envelope.
165
171
  */
166
172
  private lastEnvelopeKey: string | undefined
173
+ private previousRequestContext: RequestContextSnapshot | undefined
174
+
175
+ private projectObservations(messages: Message[]): Message[] {
176
+ const config = this.ctx.compactionConfig
177
+ return config && config.strategy !== 'disabled' && config.deduplicateObservations !== false
178
+ ? projectObservationContext(messages, this.ctx.tools, config.preserveToolResultsFrom)
179
+ : messages
180
+ }
167
181
  /** Rich tool blocks already reported; durable history is scanned every turn. */
168
182
  private readonly warnedRichToolResults = new Set<string>()
169
183
  /**
@@ -614,7 +628,7 @@ export class IterationOrchestrator {
614
628
  ? [...baseMessages, createSystemMessage(stepPreamble)]
615
629
  : [...baseMessages]
616
630
  const messages = projectRequestRichContent(
617
- requestHistory,
631
+ this.projectObservations(requestHistory),
618
632
  this.ctx.runConfig.maxRequestRichContentBytes ?? DEFAULT_MAX_REQUEST_RICH_CONTENT_BYTES,
619
633
  )
620
634
  await this.reportUnsupportedToolResults(messages)
@@ -664,6 +678,16 @@ export class IterationOrchestrator {
664
678
  }
665
679
 
666
680
  if (this.ctx.pluginManager) {
681
+ const snapshot = snapshotRequestContext(messages)
682
+ const context = Object.freeze({
683
+ snapshot,
684
+ ...(this.previousRequestContext
685
+ ? {
686
+ change: diffRequestContext(this.previousRequestContext, snapshot),
687
+ }
688
+ : {}),
689
+ })
690
+ this.previousRequestContext = snapshot
667
691
  const hookResults = await this.ctx.pluginManager.executeHooks(
668
692
  'pre_llm_call',
669
693
  {
@@ -673,6 +697,7 @@ export class IterationOrchestrator {
673
697
  // Built inside the guard: a run with no plugins installed
674
698
  // pays nothing for a projection nobody reads.
675
699
  request: Object.freeze({
700
+ context,
676
701
  model: stepModel,
677
702
  // Copied per turn, not handed over live: these are the
678
703
  // run's own message objects, and a hook writing into
@@ -752,6 +777,7 @@ export class IterationOrchestrator {
752
777
  cacheControl: { type: 'auto' },
753
778
  ...(runConfig.thinking ? { thinking: runConfig.thinking } : {}),
754
779
  ...(runConfig.effort ? { effort: runConfig.effort } : {}),
780
+ ...(!forceFinalize && runConfig.webSearch ? { webSearch: runConfig.webSearch } : {}),
755
781
  // Thread the run abort into the model call so a Stop tears the
756
782
  // in-flight turn down (provider passes it to fetch; the consumer
757
783
  // also races it). Inert when never aborted.
@@ -2424,7 +2450,7 @@ export class IterationOrchestrator {
2424
2450
  ),
2425
2451
  ]
2426
2452
  const finalMessages = projectRequestRichContent(
2427
- finalHistory,
2453
+ this.projectObservations(finalHistory),
2428
2454
  this.ctx.runConfig.maxRequestRichContentBytes ?? DEFAULT_MAX_REQUEST_RICH_CONTENT_BYTES,
2429
2455
  )
2430
2456
  await this.reportUnsupportedToolResults(finalMessages)
@@ -1,5 +1,8 @@
1
1
  import { type Span, SpanStatusCode } from '@opentelemetry/api'
2
- import { assertNativeStructuredOutputSupported } from '../../../provider/capabilities.js'
2
+ import {
3
+ assertHostedWebSearchSupported,
4
+ assertNativeStructuredOutputSupported,
5
+ } from '../../../provider/capabilities.js'
3
6
  import { isProviderRequestError } from '../../../provider/errors.js'
4
7
  import { GENAI, NAMZU, chatSpanName, parentContext } from '../../../telemetry/attributes.js'
5
8
  import {
@@ -155,6 +158,7 @@ export async function* streamProviderTurn(
155
158
  },
156
159
  ): AsyncGenerator<RunEvent, StreamingTurnResult> {
157
160
  assertNativeStructuredOutputSupported(provider, params)
161
+ assertHostedWebSearchSupported(provider, params)
158
162
  // The `chat {model}` span the GenAI conventions require. There was none:
159
163
  // `chatSpanName` existed with zero call sites, so a trace carried no LLM
160
164
  // latency at all and the token counts landed on the iteration span
@@ -340,6 +344,15 @@ export async function* streamProviderTurn(
340
344
  recordTimeToFirstToken(params.model, Date.now() - callStartedAt)
341
345
  }
342
346
 
347
+ if (chunk.delta.hostedTool) {
348
+ await emitEvent({
349
+ type: 'hosted_tool',
350
+ runId,
351
+ iteration,
352
+ tool: chunk.delta.hostedTool,
353
+ })
354
+ yield* drainPending()
355
+ }
343
356
  if (chunk.delta.citation) citations.push(chunk.delta.citation)
344
357
 
345
358
  const reasoning = chunk.delta.reasoning
@@ -0,0 +1,86 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { findRetainedIndices } from '../../compaction/retention.js'
3
+ import { estimateMessageTokens } from '../../compaction/token-estimate.js'
4
+ import { isClearedToolResult } from '../../compaction/tool-result-editing.js'
5
+ import type { Message, ToolMessage } from '../../types/message/index.js'
6
+ import type { ToolRegistryContract } from '../../types/tool/index.js'
7
+
8
+ /**
9
+ * Request-only exact observation masking. Every reference targets a full result
10
+ * in THIS projection. Recompute from canonical history, never from yesterday's
11
+ * mask, so removing a former representative cannot leave a dangling reference.
12
+ * No tool executions are skipped, and equal output is not a freshness claim.
13
+ */
14
+ export function projectObservationContext(
15
+ messages: Message[],
16
+ tools: Pick<ToolRegistryContract, 'get'>,
17
+ preserveToolResultsFrom: readonly string[] = [],
18
+ ): Message[] {
19
+ const calls = new Map<string, { name: string; arguments: string } | null>()
20
+ for (const message of messages) {
21
+ if (message.role !== 'assistant') continue
22
+ for (const call of message.toolCalls ?? []) {
23
+ // Ambiguous IDs cannot prove which observation belongs to which call.
24
+ if (calls.has(call.id)) calls.set(call.id, null)
25
+ else calls.set(call.id, call.metadata?.inputTruncated ? null : call.function)
26
+ }
27
+ }
28
+ const resultCounts = new Map<string, number>()
29
+ for (const message of messages) {
30
+ if (message.role === 'tool') {
31
+ resultCounts.set(message.toolCallId, (resultCounts.get(message.toolCallId) ?? 0) + 1)
32
+ }
33
+ }
34
+ const protectedIndices = findRetainedIndices(messages)
35
+ const representatives = new Map<string, ToolMessage>()
36
+ let projected: Message[] | undefined
37
+ for (let index = 0; index < messages.length; index++) {
38
+ const message = messages[index]
39
+ if (
40
+ !message ||
41
+ message.role !== 'tool' ||
42
+ message.isError ||
43
+ typeof message.content !== 'string' ||
44
+ message.content.length < 1024 ||
45
+ isClearedToolResult(message.content) ||
46
+ resultCounts.get(message.toolCallId) !== 1
47
+ )
48
+ continue
49
+ const call = calls.get(message.toolCallId)
50
+ if (!call || preserveToolResultsFrom.includes(call.name)) continue
51
+ const definition = tools.get(call.name)
52
+ if (!definition) continue
53
+ try {
54
+ const input = definition.inputSchema.safeParse(JSON.parse(call.arguments))
55
+ if (
56
+ !input.success ||
57
+ definition.isReadOnly?.(input.data) !== true ||
58
+ definition.isDestructive?.(input.data) === true
59
+ )
60
+ continue
61
+ } catch {
62
+ // Classification is advisory; invalid input or a broken custom predicate
63
+ // must leave evidence intact, not abort the request.
64
+ continue
65
+ }
66
+ // Exact arguments intentionally: equivalent JSON with different formatting
67
+ // is a missed optimization, not permission to merge different file ranges.
68
+ const key = createHash('sha256')
69
+ .update(JSON.stringify([call.name, call.arguments, message.content]))
70
+ .digest('hex')
71
+ const representative = representatives.get(key)
72
+ if (!representative) {
73
+ representatives.set(key, message)
74
+ continue
75
+ }
76
+ if (protectedIndices.has(index)) continue
77
+ const replacement: ToolMessage = {
78
+ ...message,
79
+ content: `[Duplicate observation: this call returned exactly the same text as tool result ${JSON.stringify(representative.toolCallId)}, whose full content remains in this request. Both calls occurred. This is historical evidence, not a claim that external state is still unchanged.]`,
80
+ }
81
+ if (estimateMessageTokens(replacement) >= estimateMessageTokens(message)) continue
82
+ projected ??= [...messages]
83
+ projected[index] = replacement
84
+ }
85
+ return projected ?? messages
86
+ }
@@ -0,0 +1,103 @@
1
+ import { createHash } from 'node:crypto'
2
+ import type { Message, MessageRole } from '../../types/message/index.js'
3
+
4
+ /** A content block present at the SDK's provider-input boundary, not a file freshness claim. */
5
+ export interface RequestContextPart {
6
+ readonly messageIndex: number
7
+ readonly partIndex: number
8
+ readonly role: MessageRole
9
+ readonly kind: 'text' | 'image' | 'document' | 'stored' | 'tool-call'
10
+ readonly digest: string
11
+ readonly toolCallId?: string
12
+ readonly isError?: boolean
13
+ }
14
+
15
+ export interface RequestContextSnapshot {
16
+ readonly boundary: 'provider-input'
17
+ readonly parts: readonly RequestContextPart[]
18
+ }
19
+
20
+ export interface RequestContextChange {
21
+ /** Parts from the earlier snapshot no longer present, including replaced content. */
22
+ readonly removed: readonly RequestContextPart[]
23
+ /** Parts in the new snapshot not present before. */
24
+ readonly added: readonly RequestContextPart[]
25
+ }
26
+
27
+ function canonicalJson(value: unknown): string {
28
+ return JSON.stringify(value, (_key, item: unknown) => {
29
+ if (!item || typeof item !== 'object' || Array.isArray(item)) return item
30
+ return Object.fromEntries(Object.entries(item).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)))
31
+ })
32
+ }
33
+
34
+ /**
35
+ * Inspect supplied messages after compaction and request projection. No content
36
+ * is retained in the snapshot. A digest identifies exact blocks, not semantic
37
+ * knowledge, token counts, file versions or provider-private reasoning replay.
38
+ */
39
+ export function snapshotRequestContext(messages: readonly Message[]): RequestContextSnapshot {
40
+ const parts: RequestContextPart[] = []
41
+ for (const [messageIndex, message] of messages.entries()) {
42
+ let partIndex = 0
43
+ const add = (kind: RequestContextPart['kind'], payload: unknown, toolCallId?: string) => {
44
+ parts.push(
45
+ Object.freeze({
46
+ messageIndex,
47
+ partIndex: partIndex++,
48
+ role: message.role,
49
+ kind,
50
+ digest: createHash('sha256').update(canonicalJson(payload)).digest('hex'),
51
+ ...(toolCallId !== undefined ? { toolCallId } : {}),
52
+ ...(message.role === 'tool' ? { isError: message.isError === true } : {}),
53
+ }),
54
+ )
55
+ }
56
+ const callId = message.role === 'tool' ? message.toolCallId : undefined
57
+ if (typeof message.content === 'string') add('text', message.content, callId)
58
+ else if (Array.isArray(message.content)) {
59
+ for (const block of message.content) {
60
+ if (block.type === 'text') add('text', block.text, callId)
61
+ else add(block.type, block, callId)
62
+ }
63
+ }
64
+ if (message.role === 'user') {
65
+ for (const attachment of message.attachments ?? []) {
66
+ add(attachment.type ?? 'image', attachment)
67
+ }
68
+ }
69
+ if (message.role === 'assistant') {
70
+ for (const call of message.toolCalls ?? []) {
71
+ add('tool-call', [call.function.name, call.function.arguments], call.id)
72
+ }
73
+ }
74
+ }
75
+ return Object.freeze({ boundary: 'provider-input', parts: Object.freeze(parts) })
76
+ }
77
+
78
+ const key = (part: RequestContextPart): string =>
79
+ JSON.stringify([part.role, part.kind, part.toolCallId, part.isError, part.digest])
80
+
81
+ /** Compare occurrences, not sets: removing one of two identical blocks is a removal. */
82
+ export function diffRequestContext(
83
+ previous: RequestContextSnapshot,
84
+ next: RequestContextSnapshot,
85
+ ): RequestContextChange {
86
+ const unmatched = new Map<string, RequestContextPart[]>()
87
+ for (const part of previous.parts) {
88
+ const identity = key(part)
89
+ const occurrences = unmatched.get(identity) ?? []
90
+ occurrences.push(part)
91
+ unmatched.set(identity, occurrences)
92
+ }
93
+ const added: RequestContextPart[] = []
94
+ for (const part of next.parts) {
95
+ const occurrences = unmatched.get(key(part))
96
+ if (occurrences?.length) occurrences.pop()
97
+ else added.push(part)
98
+ }
99
+ const removed = [...unmatched.values()]
100
+ .flat()
101
+ .sort((a, b) => a.messageIndex - b.messageIndex || a.partIndex - b.partIndex)
102
+ return Object.freeze({ removed: Object.freeze(removed), added: Object.freeze(added) })
103
+ }
@@ -45,20 +45,25 @@ export interface RequestImageIdentity {
45
45
  const userMarker = (kind: RichKind): string =>
46
46
  `[${kind} omitted from this model request to keep the accumulated rich-content payload within its configured size limit; attach it again in a new message if it is still needed.]`
47
47
 
48
+ // Losing an observation does not undo the action that produced it. This
49
+ // projection knows the result bytes, not whether repeating its tool is safe.
50
+ const toolRecovery =
51
+ 'The original result is retained in conversation history, but this content is not available in the current model request. Recover it from an available saved artifact or a read-only observation if needed. Do not repeat a state-changing action merely to recover its output.'
52
+
48
53
  const toolMarker = (kind: RichKind): string =>
49
- `[${kind} omitted from this model request to keep the accumulated rich-content payload within its configured size limit; call the producing tool again if it is still needed.]`
54
+ `[${kind} omitted from this model request to keep the accumulated rich-content payload within its configured size limit. ${toolRecovery}]`
50
55
 
51
56
  const rejectedUserImageMarker = (): string =>
52
57
  '[image omitted from this model request because the provider rejected this image; attach a corrected image in a new message if it is still needed.]'
53
58
 
54
59
  const rejectedToolImageMarker = (): string =>
55
- '[image omitted from this model request because the provider rejected this image; call the producing tool again after correcting its image source if it is still needed.]'
60
+ `[image omitted from this model request because the provider rejected this image. ${toolRecovery} Correct the image source before presenting it again.]`
56
61
 
57
62
  const invalidUserImageMarker = (): string =>
58
63
  '[image omitted from this model request because its encoded bytes are not a complete supported raster matching the declared media type; attach a corrected image in a new message if it is still needed.]'
59
64
 
60
65
  const invalidToolImageMarker = (): string =>
61
- '[image omitted from this model request because the producing tool returned encoded bytes that are not a complete supported raster matching the declared media type; call the tool again after correcting its image source if it is still needed.]'
66
+ `[image omitted from this model request because the producing tool returned encoded bytes that are not a complete supported raster matching the declared media type. ${toolRecovery} Correct the image source before presenting it again.]`
62
67
 
63
68
  function assertInlineAttachment(
64
69
  attachment: MessageAttachment,
@@ -25,6 +25,7 @@ import type { ToolRegistry } from '../../registry/tool/execute.js'
25
25
  import { isTrustedReadOnly } from '../../tools/trusted-read-only.js'
26
26
  import type { HITLResumeDecision, ResumeHandler, ToolCallSummary } from '../../types/hitl/index.js'
27
27
  import type { ApprovalPolicy } from '../../types/hitl/policy.js'
28
+ import type { RunId } from '../../types/ids/index.js'
28
29
  import { PLAN_MODE_REFUSAL } from '../../types/permission/index.js'
29
30
 
30
31
  export type ReviewMode =
@@ -141,6 +142,8 @@ export function batchNeedsReview(
141
142
 
142
143
  /** The batch a person is asked about. */
143
144
  export interface ToolReviewRequest {
145
+ /** Originating run, preserved by createReviewHandler for host attribution. */
146
+ readonly runId?: RunId
144
147
  readonly toolCalls: readonly ToolCallSummary[]
145
148
  }
146
149
 
@@ -204,7 +207,10 @@ export function createReviewHandler(options: ReviewPolicyOptions = {}): ResumeHa
204
207
  if (mode === 'auto' || !prompt || remembered.all) {
205
208
  return { action: 'approve_tools' }
206
209
  }
207
- const answer = await prompt({ toolCalls: request.toolCalls })
210
+ const answer = await prompt({
211
+ runId: request.runId,
212
+ toolCalls: request.toolCalls,
213
+ })
208
214
  switch (answer.kind) {
209
215
  case 'approve':
210
216
  return { action: 'approve_tools' }
@@ -20,6 +20,7 @@ import { ToolExecutor } from './executor.js'
20
20
  export type EmitEvent = (event: RunEvent) => Promise<void>
21
21
 
22
22
  export interface ToolingBootstrapConfig {
23
+ fileReadTracker?: import('../../types/tool/index.js').FileReadTracker
23
24
  tools: ToolRegistryContract
24
25
  runId: RunId
25
26
  workingDirectory: string
@@ -67,6 +68,7 @@ export class ToolingBootstrap {
67
68
  return new ToolExecutor(
68
69
  {
69
70
  tools: config.tools,
71
+ ...(config.fileReadTracker ? { fileReadTracker: config.fileReadTracker } : {}),
70
72
  runId: config.runId,
71
73
  workingDirectory: config.workingDirectory,
72
74
  ...(config.additionalDirectories?.length
@@ -51,6 +51,14 @@ async function run() {
51
51
  try { canonicalRoot = await fs.realpath(plan.root); }
52
52
  catch (error) { if (missing(error)) return; throw error; }
53
53
  if (canonicalRoot !== path.resolve(plan.root)) throw new Error('File walk root follows a symbolic link; use its authorized real directory');
54
+ const rootInfo = await fs.lstat(plan.root);
55
+ if (rootInfo.isFile()) {
56
+ visit(++visited);
57
+ if (matchers.some(matcher => matcher.test(path.basename(plan.root)))) {
58
+ await write({type:'entry', path:plan.root, size:rootInfo.size});
59
+ }
60
+ return;
61
+ }
54
62
  let start = plan.root;
55
63
  for (const part of plan.prefix) {
56
64
  start = path.join(start, part);