@namzu/sdk 55.1.0 → 56.0.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 (94) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/compaction/serializer.d.ts.map +1 -1
  3. package/dist/compaction/serializer.js +27 -15
  4. package/dist/compaction/serializer.js.map +1 -1
  5. package/dist/compaction/verifier.d.ts.map +1 -1
  6. package/dist/compaction/verifier.js +3 -2
  7. package/dist/compaction/verifier.js.map +1 -1
  8. package/dist/constants/compaction/index.d.ts +3 -1
  9. package/dist/constants/compaction/index.d.ts.map +1 -1
  10. package/dist/constants/compaction/index.js +3 -1
  11. package/dist/constants/compaction/index.js.map +1 -1
  12. package/dist/registry/tool/screen.d.ts.map +1 -1
  13. package/dist/registry/tool/screen.js +3 -1
  14. package/dist/registry/tool/screen.js.map +1 -1
  15. package/dist/runtime/query/executor/tool-call-admission.d.ts.map +1 -1
  16. package/dist/runtime/query/executor/tool-call-admission.js +6 -1
  17. package/dist/runtime/query/executor/tool-call-admission.js.map +1 -1
  18. package/dist/runtime/query/file-evidence-context.d.ts +4 -0
  19. package/dist/runtime/query/file-evidence-context.d.ts.map +1 -1
  20. package/dist/runtime/query/file-evidence-context.js +5 -1
  21. package/dist/runtime/query/file-evidence-context.js.map +1 -1
  22. package/dist/runtime/query/file-evidence-replay.js +7 -3
  23. package/dist/runtime/query/file-evidence-replay.js.map +1 -1
  24. package/dist/runtime/query/iteration/index.d.ts +2 -0
  25. package/dist/runtime/query/iteration/index.d.ts.map +1 -1
  26. package/dist/runtime/query/iteration/index.js +19 -11
  27. package/dist/runtime/query/iteration/index.js.map +1 -1
  28. package/dist/runtime/query/iteration/phases/tool-review.d.ts.map +1 -1
  29. package/dist/runtime/query/iteration/phases/tool-review.js +4 -1
  30. package/dist/runtime/query/iteration/phases/tool-review.js.map +1 -1
  31. package/dist/runtime/query/iteration/step-context.d.ts +42 -0
  32. package/dist/runtime/query/iteration/step-context.d.ts.map +1 -1
  33. package/dist/runtime/query/iteration/step-context.js +114 -0
  34. package/dist/runtime/query/iteration/step-context.js.map +1 -1
  35. package/dist/runtime/query/iteration/step-shaping.d.ts +9 -1
  36. package/dist/runtime/query/iteration/step-shaping.d.ts.map +1 -1
  37. package/dist/runtime/query/iteration/step-shaping.js +21 -11
  38. package/dist/runtime/query/iteration/step-shaping.js.map +1 -1
  39. package/dist/runtime/query/review-policy.d.ts.map +1 -1
  40. package/dist/runtime/query/review-policy.js +13 -1
  41. package/dist/runtime/query/review-policy.js.map +1 -1
  42. package/dist/runtime/query/steering.d.ts +13 -0
  43. package/dist/runtime/query/steering.d.ts.map +1 -1
  44. package/dist/runtime/query/steering.js +42 -0
  45. package/dist/runtime/query/steering.js.map +1 -1
  46. package/dist/scheduler/completion-inbox.js +1 -1
  47. package/dist/scheduler/completion-inbox.js.map +1 -1
  48. package/dist/tools/builtins/bash.d.ts +3 -5
  49. package/dist/tools/builtins/bash.d.ts.map +1 -1
  50. package/dist/tools/builtins/bash.js +14 -15
  51. package/dist/tools/builtins/bash.js.map +1 -1
  52. package/dist/tools/builtins/edit.js +1 -1
  53. package/dist/tools/builtins/edit.js.map +1 -1
  54. package/dist/tools/builtins/read-file.d.ts.map +1 -1
  55. package/dist/tools/builtins/read-file.js +19 -6
  56. package/dist/tools/builtins/read-file.js.map +1 -1
  57. package/dist/tools/builtins/read-render.d.ts +3 -0
  58. package/dist/tools/builtins/read-render.d.ts.map +1 -1
  59. package/dist/tools/builtins/read-render.js +40 -9
  60. package/dist/tools/builtins/read-render.js.map +1 -1
  61. package/dist/tools/env-scrub.d.ts +8 -0
  62. package/dist/tools/env-scrub.d.ts.map +1 -1
  63. package/dist/tools/env-scrub.js +13 -0
  64. package/dist/tools/env-scrub.js.map +1 -1
  65. package/dist/turn/evidence-recall.d.ts +15 -3
  66. package/dist/turn/evidence-recall.d.ts.map +1 -1
  67. package/dist/turn/evidence-recall.js +116 -27
  68. package/dist/turn/evidence-recall.js.map +1 -1
  69. package/dist/types/message/notice-frame.d.ts +1 -1
  70. package/dist/types/message/notice-frame.d.ts.map +1 -1
  71. package/dist/types/message/notice-frame.js +1 -0
  72. package/dist/types/message/notice-frame.js.map +1 -1
  73. package/package.json +1 -1
  74. package/src/compaction/serializer.ts +29 -17
  75. package/src/compaction/verifier.ts +3 -2
  76. package/src/constants/compaction/index.ts +3 -1
  77. package/src/registry/tool/screen.ts +3 -1
  78. package/src/runtime/query/executor/tool-call-admission.ts +5 -1
  79. package/src/runtime/query/file-evidence-context.ts +8 -1
  80. package/src/runtime/query/file-evidence-replay.ts +6 -3
  81. package/src/runtime/query/iteration/index.ts +27 -13
  82. package/src/runtime/query/iteration/phases/tool-review.ts +3 -0
  83. package/src/runtime/query/iteration/step-context.ts +138 -0
  84. package/src/runtime/query/iteration/step-shaping.ts +39 -13
  85. package/src/runtime/query/review-policy.ts +19 -1
  86. package/src/runtime/query/steering.ts +51 -0
  87. package/src/scheduler/completion-inbox.ts +1 -1
  88. package/src/tools/builtins/bash.ts +14 -14
  89. package/src/tools/builtins/edit.ts +1 -1
  90. package/src/tools/builtins/read-file.ts +18 -7
  91. package/src/tools/builtins/read-render.ts +40 -9
  92. package/src/tools/env-scrub.ts +13 -0
  93. package/src/turn/evidence-recall.ts +125 -30
  94. package/src/types/message/notice-frame.ts +3 -1
@@ -716,17 +716,20 @@ function observeRead(
716
716
  * compared, which is also what makes the figure above exact.
717
717
  */
718
718
  function wholeFileRenderUnits(body: string, window: ReadWindowRequest): number | undefined {
719
+ if (body === '') return undefined
719
720
  const lines = countLines(body)
720
721
  const { start, end } = resolveReadWindow(window, lines)
721
722
  if (start !== 0 || end < lines) return undefined
722
- return body.length + lines + lineNumberUnits(lines)
723
+ // The rendering numbers each line and joins them, so a final newline is not
724
+ // a row of its own and is not carried as a trailing separator.
725
+ return body.length - (body.endsWith('\n') ? 1 : 0) + lines + lineNumberUnits(lines)
723
726
  }
724
727
 
725
- /** Lines the way `String.split('\n')` counts them, without building the array. */
728
+ /** Lines the way a read counts them, without building the array: a final newline ends a line. */
726
729
  function countLines(body: string): number {
727
730
  let lines = 1
728
731
  for (let at = body.indexOf('\n'); at !== -1; at = body.indexOf('\n', at + 1)) lines++
729
- return lines
732
+ return body.endsWith('\n') ? lines - 1 : lines
730
733
  }
731
734
 
732
735
  /** Units the line numbers `1..lines` occupy, counted by decade rather than one by one. */
@@ -28,6 +28,7 @@ import {
28
28
  import { getTracer } from '../../../telemetry/runtime-accessors.js'
29
29
  import { STRUCTURED_OUTPUT_TOOL_NAME } from '../../../tools/builtins/structuredOutput.js'
30
30
  import type { ToolManager } from '../../../toolsets/manager.js'
31
+ import { splitAtEvidenceBlocks } from '../../../turn/evidence-recall.js'
31
32
  import type { CostInfo, TokenUsage } from '../../../types/common/index.js'
32
33
  import { NamzuError } from '../../../types/errors/index.js'
33
34
  import type { MessageId, SessionId, TurnId } from '../../../types/ids/index.js'
@@ -57,6 +58,7 @@ import { createCallbackInference } from '../callback-inference.js'
57
58
  import { SandboxAcquisitionStopped } from '../deferred-sandbox.js'
58
59
  import { deliverDurableInbound } from '../durable-inbound.js'
59
60
  import type { ToolCallOutcome } from '../executor.js'
61
+ import { FILE_EVIDENCE_NONE } from '../file-evidence-context.js'
60
62
  import { projectObservationContext } from '../observation-context.js'
61
63
  import { applyLifecycleHookResults } from '../plugin-hooks.js'
62
64
  import {
@@ -102,14 +104,16 @@ import {
102
104
  streamWithProviderRejectedImageRecovery,
103
105
  } from './provider-rejected-image.js'
104
106
  import { type MediaRemoval, RequestRepair } from './request-repair.js'
105
- import { placeBeforeLatestOperatorMessage } from './step-context.js'
107
+ import { TurnContextCarry } from './step-context.js'
106
108
  import {
109
+ FILE_EVIDENCE_KEY,
107
110
  type StepShaping,
108
111
  appendWorkContext,
109
112
  beforeStep,
110
113
  prepareStep,
111
114
  selectContextModel,
112
115
  stepContextMessage,
116
+ workContextBlocks,
113
117
  } from './step-shaping.js'
114
118
  import { streamProviderTurn } from './stream-turn.js'
115
119
 
@@ -190,6 +194,8 @@ export class IterationOrchestrator {
190
194
  * one process must not suppress each other's first envelope.
191
195
  */
192
196
  private lastEnvelopeKey: string | undefined
197
+ /** Where this turn's request-only data first went, so it stays there. */
198
+ private readonly turnContext = new TurnContextCarry()
193
199
  private previousRequestContext: RequestContextSnapshot | undefined
194
200
 
195
201
  private projectObservations(messages: Message[]): Message[] {
@@ -895,27 +901,35 @@ export class IterationOrchestrator {
895
901
  ? [...workingMemory.history, createSystemMessage(stepPreamble)]
896
902
  : [...workingMemory.history]
897
903
  if (workingMemory.context) requestHistory.push(workingMemory.context)
898
- // The blocks of data that belong to this turn (recalled memory and
899
- // evidence, the context contributions) sit just before the
900
- // operator's latest message, so the request is the last thing the
901
- // model reads. The short reminders and notices stay after it.
902
- placeBeforeLatestOperatorMessage(
903
- requestHistory,
904
- [contextText, step.context]
905
- .filter((text): text is string => Boolean(text))
906
- .map(stepContextMessage),
907
- )
908
904
  // After the history, never ahead of it: a system message in front
909
905
  // of the conversation moves the prefix every cached request shares,
910
906
  // and a mode change would re-key the whole conversation.
911
907
  if (policyNotice) requestHistory.push(stepContextMessage(policyNotice))
912
- const messages = projectRequestRichContent(
908
+ const projected = projectRequestRichContent(
913
909
  this.projectObservations(requestHistory),
914
910
  this.ctx.turnConfig.maxRequestRichContentBytes ??
915
911
  DEFAULT_MAX_REQUEST_RICH_CONTENT_BYTES,
916
912
  { imagesReadable: this.modelReadsImages(stepModel) },
917
913
  )
918
- appendWorkContext(shaping, messages, iterationNum, step)
914
+ // The blocks of data that belong to this turn (recalled memory and
915
+ // evidence, the context contributions, the work context) sit just
916
+ // before the operator's latest message the first time the model
917
+ // answers it, so the request is the last thing it reads. They stay
918
+ // there on every later call of the turn, and a block that first
919
+ // appears on a later call follows the history of that moment, so each
920
+ // request begins with the whole of the one before it. The short
921
+ // reminders and notices stay after the history.
922
+ const messages = [...projected]
923
+ this.turnContext.place(
924
+ messages,
925
+ requestHistory,
926
+ workingMemory.history.at(-1),
927
+ [contextText, ...(step.context ? splitAtEvidenceBlocks(step.context) : [])]
928
+ .filter((text): text is string => Boolean(text))
929
+ .map(stepContextMessage),
930
+ workContextBlocks(shaping, projected, iterationNum, step),
931
+ { [FILE_EVIDENCE_KEY]: stepContextMessage(FILE_EVIDENCE_NONE) },
932
+ )
919
933
  if (closingDirective) messages.push(closingDirective)
920
934
  await this.reportUnsupportedToolResults(messages)
921
935
  yield* this.ctx.drainPending()
@@ -28,6 +28,7 @@ import {
28
28
  formatMonitorNote,
29
29
  takeNoticeMessage,
30
30
  takeSteeringMessage,
31
+ takeWithheldEnvNotice,
31
32
  } from '../../steering.js'
32
33
  import { type IterationContext, awaitDecisionDurably } from './context.js'
33
34
  import { PerCallReview } from './per-call-review.js'
@@ -295,6 +296,8 @@ export async function* runToolReview(
295
296
  },
296
297
  )
297
298
  if (monitorNotice) ctx.recorder.pushMessage(monitorNotice)
299
+ const credentialNotice = takeWithheldEnvNotice(ctx.recorder.messages, batch.observations)
300
+ if (credentialNotice) ctx.recorder.pushMessage(credentialNotice)
298
301
  if (jobNoticeDelivered) ctx.onJobNoticeDelivered?.()
299
302
  if (monitorNoticeDelivered) ctx.onMonitorNoticeDelivered?.()
300
303
  // The complete tool-result batch is already in history before host policy
@@ -44,3 +44,141 @@ export function placeBeforeLatestOperatorMessage(history: Message[], blocks: rea
44
44
  }
45
45
  history.splice(at, 0, ...blocks)
46
46
  }
47
+
48
+ interface PlacedBlock {
49
+ readonly text: string
50
+ /** The operator message this block precedes, or the history message it follows. */
51
+ at: { readonly before: Message } | { readonly after: Message | undefined }
52
+ }
53
+
54
+ function textOf(message: Message): string {
55
+ return typeof message.content === 'string' ? message.content : JSON.stringify(message.content)
56
+ }
57
+
58
+ /** The operator message the model is about to answer, when there is one. */
59
+ function pendingOperatorMessage(history: readonly Message[]): Message | undefined {
60
+ for (let i = history.length - 1; i >= 0; i--) {
61
+ const message = history[i] as Message
62
+ if (isOperatorUserMessage(message)) return message
63
+ if (message.role === 'assistant' || message.role === 'tool') return undefined
64
+ }
65
+ return undefined
66
+ }
67
+
68
+ /** A block whose own text says that a later one replaces it, under the name of what it reports. */
69
+ export interface ReplaceableBlock {
70
+ readonly key: string
71
+ readonly message: Message
72
+ }
73
+
74
+ /** How many superseded versions of a replaceable block still go with the latest. */
75
+ export const EARLIER_VERSIONS_KEPT = 2
76
+
77
+ interface VersionedBlock extends PlacedBlock {
78
+ readonly message: Message
79
+ }
80
+
81
+ /**
82
+ * Where a turn's request-only data sits, so it stays put from call to call.
83
+ *
84
+ * What a block says is the step's own business: a block it offers again with
85
+ * the same text is the same block, and it keeps the place it first had; a
86
+ * block it no longer offers is withdrawn, so corrected data never lingers.
87
+ * A block seen for the first time goes before the operator's message when that
88
+ * message is what the model is answering, and otherwise after the history of
89
+ * that moment. So the requests of a turn share their opening up to the first
90
+ * block that changed, and a caching driver keeps reading the same prefix
91
+ * instead of re-reading the turn from the operator's message on.
92
+ *
93
+ * A block that says of itself that a later one replaces it (the file
94
+ * evidence) is never taken back. When its text changes, the earlier text stays
95
+ * where it went and the new text follows the history of that moment, which is
96
+ * how the block itself tells the model to read it; when it stops holding, a
97
+ * short block says so. Only the latest version and the last
98
+ * `EARLIER_VERSIONS_KEPT` before it are carried, and an earlier version whose
99
+ * place a compaction rewrote is dropped, so the stale text stays small. Until
100
+ * a version is dropped no request of the turn rewrites the one before it.
101
+ *
102
+ * One carry per turn. A position whose message has left the history (a
103
+ * compaction rewrote it) is given up and the block follows the history.
104
+ */
105
+ export class TurnContextCarry {
106
+ private placed = new Map<string, PlacedBlock>()
107
+ private readonly versions = new Map<string, VersionedBlock[]>()
108
+
109
+ /**
110
+ * Put `blocks` and the `replaceable` ones into `request`, the projected copy
111
+ * of `history` (same length and order). `end` is the last message of the
112
+ * turn's own history. `retractions` says, per replaceable key, what to
113
+ * write when an earlier block of that key no longer holds.
114
+ */
115
+ place(
116
+ request: Message[],
117
+ history: readonly Message[],
118
+ end: Message | undefined,
119
+ blocks: readonly Message[],
120
+ replaceable: readonly ReplaceableBlock[] = [],
121
+ retractions: Readonly<Record<string, Message>> = {},
122
+ ): void {
123
+ const index = (message: Message | undefined) =>
124
+ message ? history.lastIndexOf(message) : history.length - 1
125
+ const operator = pendingOperatorMessage(history)
126
+ const positionOf = (slot: PlacedBlock) =>
127
+ 'before' in slot.at ? index(slot.at.before) : index(slot.at.after) + 1
128
+ const anchorOf = (slot: PlacedBlock) => ('before' in slot.at ? slot.at.before : slot.at.after)
129
+ const next = new Map<string, PlacedBlock>()
130
+ const inserts: { at: number; message: Message }[] = []
131
+ for (const message of blocks) {
132
+ const text = textOf(message)
133
+ if (next.has(text)) continue
134
+ let slot: PlacedBlock | undefined = this.placed.get(text)
135
+ if (slot) {
136
+ const anchor = anchorOf(slot)
137
+ if (anchor && index(anchor) < 0) slot = { text, at: { after: end } }
138
+ } else {
139
+ slot = { text, at: operator ? { before: operator } : { after: end } }
140
+ }
141
+ next.set(text, slot)
142
+ inserts.push({ at: positionOf(slot), message })
143
+ }
144
+ this.placed = next
145
+
146
+ const offered = new Map(replaceable.map((block) => [block.key, block.message]))
147
+ for (const key of new Set([...this.versions.keys(), ...offered.keys()])) {
148
+ const held = this.versions.get(key) ?? []
149
+ const wanted = offered.get(key) ?? retractions[key]
150
+ const last = held.at(-1)
151
+ if (wanted !== undefined && (last === undefined || last.text !== textOf(wanted)))
152
+ held.push({
153
+ text: textOf(wanted),
154
+ message: wanted,
155
+ // The first version goes where the other blocks go; a later one
156
+ // follows the history, after the one it replaces.
157
+ at: last === undefined && operator ? { before: operator } : { after: end },
158
+ })
159
+ // An earlier version whose place a compaction rewrote is given up: the
160
+ // request is rewritten at that point anyway, and stacking stale copies
161
+ // at the end would only add bulk. The latest version is never given up.
162
+ const kept = held.filter((version, i) => {
163
+ if (i === held.length - 1) return true
164
+ const anchor = anchorOf(version)
165
+ return !anchor || index(anchor) >= 0
166
+ })
167
+ // Only the latest and a few earlier versions are carried, so a long turn
168
+ // does not grow by a full copy per change.
169
+ kept.splice(0, Math.max(0, kept.length - (EARLIER_VERSIONS_KEPT + 1)))
170
+ this.versions.set(key, kept)
171
+ for (const version of kept) {
172
+ const anchor = anchorOf(version)
173
+ if (anchor && index(anchor) < 0) version.at = { after: end }
174
+ inserts.push({ at: positionOf(version), message: version.message })
175
+ }
176
+ }
177
+
178
+ // Back to front, so an earlier position is not shifted by a later one;
179
+ // blocks at one position keep the order they were offered in.
180
+ const order = inserts.map((entry, position) => ({ ...entry, position }))
181
+ order.sort((a, b) => b.at - a.at || b.position - a.position)
182
+ for (const entry of order) request.splice(entry.at, 0, entry.message)
183
+ }
184
+ }
@@ -20,7 +20,11 @@ import { toErrorMessage } from '../../../utils/error.js'
20
20
  import { createCallbackInference } from '../callback-inference.js'
21
21
  import { measureContext } from './phases/compaction.js'
22
22
  import type { IterationContext } from './phases/index.js'
23
- import { placeBeforeLatestOperatorMessage, stepContextMessage } from './step-context.js'
23
+ import {
24
+ type ReplaceableBlock,
25
+ placeBeforeLatestOperatorMessage,
26
+ stepContextMessage,
27
+ } from './step-context.js'
24
28
 
25
29
  /**
26
30
  * How the next request is shaped: admission, preparation, and the context
@@ -51,28 +55,50 @@ export function appendWorkContext(
51
55
  stepNumber: number,
52
56
  prepared: PrepareStepResult,
53
57
  ): void {
58
+ // Per-turn data goes in front of the operator's new message while that is
59
+ // what the model is answering, like the other blocks of request-only data.
60
+ placeBeforeLatestOperatorMessage(
61
+ messages,
62
+ workContextBlocks(shaping, messages, stepNumber, prepared).map((block) => block.message),
63
+ )
64
+ }
65
+
66
+ /** What the two work-context blocks report; each tells the model a later one replaces it. */
67
+ export const OWNED_WORK_KEY = 'owned-work'
68
+ export const FILE_EVIDENCE_KEY = 'file-evidence'
69
+
70
+ /**
71
+ * The work-context blocks for a request whose projected messages are
72
+ * `messages`, each under the name of what it reports.
73
+ */
74
+ export function workContextBlocks(
75
+ shaping: StepShaping,
76
+ messages: readonly Message[],
77
+ stepNumber: number,
78
+ prepared: PrepareStepResult,
79
+ ): ReplaceableBlock[] {
54
80
  const { ctx } = shaping
55
81
 
56
- const contributions = [
57
- ctx.completionInbox?.describeOwnedWork(),
58
- ctx.toolExecutor.describeFileEvidence(messages),
59
- ].filter((content): content is string => Boolean(content))
60
- if (contributions.length === 0) return
61
- const placed: Message[] = []
82
+ const contributions = (
83
+ [
84
+ [OWNED_WORK_KEY, ctx.completionInbox?.describeOwnedWork()],
85
+ [FILE_EVIDENCE_KEY, ctx.toolExecutor.describeFileEvidence(messages)],
86
+ ] as [string, string | undefined][]
87
+ ).filter((entry): entry is [string, string] => Boolean(entry[1]))
88
+ if (contributions.length === 0) return []
89
+ const placed: ReplaceableBlock[] = []
62
90
  let room = stepContext(shaping, stepNumber, prepared).contextBudget?.remainingTokens ?? 0
63
91
  // Leave room for the actual task; admit whole contributions, never dangling partial references.
64
- if (room < 1_500) return
65
- for (const content of contributions) {
92
+ if (room < 1_500) return []
93
+ for (const [key, content] of contributions) {
66
94
  if (!content || content.length > 8_000) continue
67
95
  const message = stepContextMessage(content)
68
96
  const tokens = estimateMessageTokens(message)
69
97
  if (tokens > Math.min(2_000, room - 1_000)) continue
70
- placed.push(message)
98
+ placed.push({ key, message })
71
99
  room -= tokens
72
100
  }
73
- // Per-turn data goes in front of the operator's new message while that is
74
- // what the model is answering, like the other blocks of request-only data.
75
- placeBeforeLatestOperatorMessage(messages, placed)
101
+ return placed
76
102
  }
77
103
 
78
104
  export function stepContext(
@@ -128,6 +128,16 @@ export const STRICT_MODE_REFUSAL =
128
128
  /** The tools `accept-edits` approves without asking. Everything else prompts. */
129
129
  export const ACCEPT_EDITS_TOOLS: ReadonlySet<string> = new Set(['edit', 'write'])
130
130
 
131
+ /**
132
+ * `write` declares every call destructive (its input cannot say whether the
133
+ * file exists), so the flag cannot be what keeps `accept-edits` from covering
134
+ * it: a new file and a replaced one are the same kind of edit to that mode.
135
+ * A path outside the roots, a protected file and a rule's ask still stop it.
136
+ */
137
+ function isFileWrite(name: string): boolean {
138
+ return name === 'write'
139
+ }
140
+
131
141
  /**
132
142
  * Writes that skip review anyway, in spite of declaring `readOnly: false`.
133
143
  *
@@ -1028,7 +1038,15 @@ export function createReviewHandler(options: ReviewPolicyOptions = {}): ResumeHa
1028
1038
  // through beside it would be approving something it did not show.
1029
1039
  if (
1030
1040
  mode === 'accept-edits' &&
1031
- (await Promise.all(request.toolCalls.map(acceptEditsAllows))).every(Boolean)
1041
+ (
1042
+ await Promise.all(
1043
+ request.toolCalls.map((tc) =>
1044
+ // The mode covers a write although the flag is set; the classifier
1045
+ // still judges one, so only this branch lifts the flag.
1046
+ acceptEditsAllows(isFileWrite(tc.name) ? { ...tc, isDestructive: false } : tc),
1047
+ ),
1048
+ )
1049
+ ).every(Boolean)
1032
1050
  ) {
1033
1051
  return { action: 'approve_tools' }
1034
1052
  }
@@ -1,3 +1,4 @@
1
+ import { withheldEnvNoticeText } from '../../tools/env-scrub.js'
1
2
  import { createRuntimeContextMessage } from '../../types/message/index.js'
2
3
  import type { Message, UserMessage } from '../../types/message/index.js'
3
4
  import { NOTICE_TAG, frameNotice, readOperatorNotice } from '../../types/message/notice-frame.js'
@@ -377,3 +378,53 @@ export function attachSteering(
377
378
  onDelivered?.(guidance)
378
379
  return next
379
380
  }
381
+
382
+ /**
383
+ * The count of credential-shaped variables a settled batch's shell results
384
+ * report as removed, or 0. The count rides in a result's `data`, never in the
385
+ * text the model reads.
386
+ */
387
+ function withheldEnvCount(
388
+ observations: readonly { readonly result: { readonly data?: unknown } }[],
389
+ ) {
390
+ let count = 0
391
+ for (const { result } of observations) {
392
+ const data = result.data
393
+ if (typeof data !== 'object' || data === null) continue
394
+ const n = (data as { withheldEnv?: unknown }).withheldEnv
395
+ if (typeof n === 'number' && Number.isInteger(n) && n > count) count = n
396
+ }
397
+ return count
398
+ }
399
+
400
+ /**
401
+ * The credential notice for a settled tool batch, as a user-role message of its
402
+ * own, or `undefined` when the batch removed nothing or the conversation
403
+ * already carries this very notice. It is sent once, and again only when the
404
+ * number of removed variables changes, so a run of failing commands does not
405
+ * repeat it. The conversation is the record, so a resumed or compacted one
406
+ * needs no counter of its own.
407
+ */
408
+ export function takeWithheldEnvNotice(
409
+ history: readonly Message[],
410
+ observations: readonly { readonly result: { readonly data?: unknown } }[],
411
+ ): UserMessage | undefined {
412
+ const count = withheldEnvCount(observations)
413
+ if (count === 0) return undefined
414
+ const body = withheldEnvNoticeText(count)
415
+ const said = history.some(
416
+ (message) =>
417
+ message.role === 'user' &&
418
+ message.source?.type === 'runtime-context' &&
419
+ message.source.kind === 'reminder' &&
420
+ typeof message.content === 'string' &&
421
+ message.content.startsWith(`<${NOTICE_TAG}-`) &&
422
+ message.content.includes(' kind="credentials">') &&
423
+ message.content.includes(body),
424
+ )
425
+ if (said) return undefined
426
+ return createRuntimeContextMessage(
427
+ frameNotice('credentials', '[Command environment]', body),
428
+ 'reminder',
429
+ )
430
+ }
@@ -479,7 +479,7 @@ export class CompletionInbox {
479
479
  stillRunningNotShown > 0
480
480
  ? ` ${shownRunning.length} running tasks are shown below, and ${stillRunningNotShown} more still running.`
481
481
  : ''
482
- return `Owned delegated work (current scheduler observations).${overflow} Result delivery is not proof that you answered the original request. Answer the latest operator message and complete the requested synthesis of available results unless the operator cancelled or changed that request. Reuse delivered results when sufficient; do not relaunch completed work. If a delivered result is no longer visible, retrieve it by task ID. A terminal state with a non-end_turn stop reason is not successful task completion. Do not repeat a synthesis already given.\n${JSON.stringify({ tasks, omitted: this.ours.size - ids.length })}`
482
+ return `Owned delegated work (current scheduler observations; a later block with this heading replaces this one).${overflow} Result delivery is not proof that you answered the original request. Answer the latest operator message and complete the requested synthesis of available results unless the operator cancelled or changed that request. Reuse delivered results when sufficient; do not relaunch completed work. If a delivered result is no longer visible, retrieve it by task ID. A terminal state with a non-end_turn stop reason is not successful task completion. Do not repeat a synthesis already given.\n${JSON.stringify({ tasks, omitted: this.ours.size - ids.length })}`
483
483
  }
484
484
 
485
485
  /**
@@ -20,7 +20,7 @@ import {
20
20
  withoutBashStartup,
21
21
  } from '../command-shell.js'
22
22
  import { defineTool } from '../defineTool.js'
23
- import { scrubInheritedEnv } from '../env-scrub.js'
23
+ import { scrubInheritedEnv, withheldEnvNoticeText } from '../env-scrub.js'
24
24
  import { createHostOutputDecoder } from '../host-output.js'
25
25
  import { looksBinary, windowText } from '../text-window.js'
26
26
  import { judgeExit } from './exit-semantics.js'
@@ -802,10 +802,10 @@ const BashToolBase = defineTool({
802
802
  failure.stdoutTruncated ? 'stdout' : '',
803
803
  failure.stderrTruncated ? 'stderr' : '',
804
804
  ].filter(Boolean)
805
- // Only on the failure path. A successful command did not need to know,
806
- // and appending this to every result would make the common case noisy
807
- // to buy nothing. A failing one is exactly where "authentication
808
- // failed" has to be distinguishable from "the variable was withheld".
805
+ // Only the failure path reports removed variables (in `data`, for the
806
+ // kernel's notice). A successful command did not need to know; a failing
807
+ // one is where "authentication failed" has to be distinguishable from
808
+ // "the variable was withheld".
809
809
  // A status the command uses to answer (no match, files differ) is a
810
810
  // result, not a failure; the model sees it as an ordinary call.
811
811
  const judged =
@@ -816,7 +816,6 @@ const BashToolBase = defineTool({
816
816
  clipped.length > 0
817
817
  ? `[${clipped.join(' and ')} ${clipped.length === 1 ? 'was' : 'were'} truncated by the host output cap. The omitted output is unavailable in this result.]`
818
818
  : '',
819
- answered ? '' : describeWithheldEnv(inherited.dropped),
820
819
  ]
821
820
  .filter(Boolean)
822
821
  .join('\n\n')
@@ -827,6 +826,11 @@ const BashToolBase = defineTool({
827
826
  data: {
828
827
  ...(exitCode !== undefined ? { exitCode } : {}),
829
828
  ...(judged?.note ? { exitNote: judged.note } : {}),
829
+ // How many variables were removed, for the kernel's own notice; the
830
+ // model's result stays what the command printed.
831
+ ...(!answered && inherited.dropped.length > 0
832
+ ? { withheldEnv: inherited.dropped.length }
833
+ : {}),
830
834
  ...(leaveSandbox ? { sandboxed: false, sandboxEscape: true } : {}),
831
835
  timedOut,
832
836
  stdoutTruncated: failure.stdoutTruncated ?? false,
@@ -904,16 +908,12 @@ function formatShellOutput(stdout: string | undefined, stderr: string | undefine
904
908
  }
905
909
 
906
910
  /**
907
- * Say that credential-shaped variables were left out of this command's
908
- * environment. The count is enough: the model needs to know a missing
909
- * credential is deliberate and who to ask, and the names are the user's data.
910
- * A command that failed because it wanted one otherwise reports an
911
- * authentication error pointing nowhere.
911
+ * The notice text for the credential-shaped variables left out of a command's
912
+ * environment (see {@link withheldEnvNoticeText}). It is sent as a notice after
913
+ * the tool batch, never written into the command's result.
912
914
  */
913
915
  export function describeWithheldEnv(dropped: readonly string[]): string {
914
- if (dropped.length === 0) return ''
915
- const one = dropped.length === 1
916
- return `NOTE: ${dropped.length} credential-shaped environment ${one ? 'variable was' : 'variables were'} removed from this command's environment, so ${one ? 'it reads' : 'they read'} as unset. Ask the user if the command needs ${one ? 'it' : 'one'}.`
916
+ return withheldEnvNoticeText(dropped.length)
917
917
  }
918
918
 
919
919
  /**
@@ -428,7 +428,7 @@ function refusedEdit(result: EditApplyFailure, path: string) {
428
428
  export const EditTool = defineTool({
429
429
  name: 'edit',
430
430
  description:
431
- 'Makes targeted edits to a file using exact string find-and-replace or line insertion. To change an existing file, use `edit`: `write` overwrites the whole body and discards earlier work on partial failure, while `edit` keeps the rest of the file byte-for-byte intact and is recoverable: if a single edit fails (old_string/oldStr ambiguous, broader restructuring needed), follow up with another `edit` instead of re-emitting the entire file via `write`. The old_string/oldStr must be unique in the file unless replace_all is true. For insertions, pass insertLine plus new_string/newStr; use insertLine: "end" to extend a file at the end. Self-budget new_string/newStr under 12000 characters before emitting the tool call; use repeated bounded edits for long sections. Preserves file formatting and indentation. To make several changes to one file that only make sense together, send them as `edits`: a list of {old_string, new_string} applied in order and committed as one write — if any of them does not apply, nothing is written and the reply names which one, so the file is never left half-changed.',
431
+ 'Makes targeted edits to a file using exact string find-and-replace or line insertion. To change an existing file, use `edit`: `write` overwrites the whole body and discards earlier work on partial failure, while `edit` keeps the rest of the file byte-for-byte intact and is recoverable: if a single edit fails (old_string/oldStr ambiguous, broader restructuring needed), follow up with another `edit` instead of re-emitting the entire file via `write`. The old_string/oldStr must be unique in the file unless replace_all is true. For insertions, pass insertLine plus new_string/newStr; use insertLine: "end" to extend a file at the end. Keep new_string/newStr under 12000 characters, because a longer payload can be cut off mid-call and fail; use repeated bounded edits for long sections. Preserves file formatting and indentation. To make several changes to one file that only make sense together, send them as `edits`: a list of {old_string, new_string} applied in order and committed as one write — if any of them does not apply, nothing is written and the reply names which one, so the file is never left half-changed.',
432
432
  inputSchema,
433
433
  modelInputSchema,
434
434
  enforceModelInput: true,
@@ -8,7 +8,7 @@ import { resolveWithinAnyReal, toolRoots } from '../paths.js'
8
8
  import { resolveRetainedOutput } from '../retained-output.js'
9
9
  import { looksBinary } from '../text-window.js'
10
10
  import { fingerprintContent } from './content-fingerprint.js'
11
- import { type RenderedRead, renderCappedRead } from './read-render.js'
11
+ import { type RenderedRead, humanBytes, renderCappedRead } from './read-render.js'
12
12
 
13
13
  const inputSchema = z.object({
14
14
  path: z.string().describe('Path to the file to read (absolute or relative)'),
@@ -98,6 +98,7 @@ export const ReadFileTool = defineTool({
98
98
  }
99
99
  }
100
100
  const content = buffer.toString('utf-8')
101
+ if (looksBinary(content)) return unreadableAsText(input.path, buffer.length, true)
101
102
  const capped = renderCappedRead(content, buffer.length, input, capOptions(input, context))
102
103
  if (!capped.ok) return refusedRead(input.path, buffer.length, capped.message, true)
103
104
  const rendered = capped.rendered
@@ -144,6 +145,7 @@ export const ReadFileTool = defineTool({
144
145
  }
145
146
  }
146
147
  const content = buffer.toString('utf-8')
148
+ if (looksBinary(content)) return unreadableAsText(filePath, buffer.length, false)
147
149
  const capped = renderCappedRead(content, buffer.length, input, capOptions(input, context))
148
150
  if (!capped.ok) return refusedRead(filePath, buffer.length, capped.message, false)
149
151
  const rendered = capped.rendered
@@ -176,6 +178,19 @@ function capOptions(
176
178
  }
177
179
  }
178
180
 
181
+ /**
182
+ * A file whose bytes are not text. The model gets a short note with the size
183
+ * and the next step, never the numbered garbage a decode would produce.
184
+ */
185
+ function unreadableAsText(path: string, bytes: number, sandboxed: boolean) {
186
+ return {
187
+ success: false as const,
188
+ output: `This file is not readable as text (${humanBytes(bytes)} of binary or non-UTF-8 data). Inspect it with a shell command such as file or xxd, or use a tool made for its format.`,
189
+ data: { path, binary: true, ...(sandboxed ? { sandboxed: true } : {}) },
190
+ facts: { kind: 'read' as const, path, binary: true, bytes },
191
+ }
192
+ }
193
+
179
194
  function refusedRead(path: string, bytes: number, message: string, sandboxed: boolean) {
180
195
  return {
181
196
  success: false as const,
@@ -202,12 +217,8 @@ function readFacts(
202
217
  path,
203
218
  bytes: buffer.length,
204
219
  ...(looksBinary(content) ? { binary: true } : {}),
205
- // A final newline ends a line; it does not start another.
206
- totalLines: content.endsWith('\n') ? rendered.totalLines - 1 : rendered.totalLines,
207
- returnedLines:
208
- content.endsWith('\n') && !rendered.partial
209
- ? rendered.returnedLines - 1
210
- : rendered.returnedLines,
220
+ totalLines: rendered.totalLines,
221
+ returnedLines: rendered.returnedLines,
211
222
  ...(rendered.partial ? { partial: true } : {}),
212
223
  }
213
224
  }