@namzu/sdk 55.1.0 → 56.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 (70) hide show
  1. package/CHANGELOG.md +12 -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/runtime/query/file-evidence-context.d.ts +4 -0
  13. package/dist/runtime/query/file-evidence-context.d.ts.map +1 -1
  14. package/dist/runtime/query/file-evidence-context.js +5 -1
  15. package/dist/runtime/query/file-evidence-context.js.map +1 -1
  16. package/dist/runtime/query/iteration/index.d.ts +2 -0
  17. package/dist/runtime/query/iteration/index.d.ts.map +1 -1
  18. package/dist/runtime/query/iteration/index.js +19 -11
  19. package/dist/runtime/query/iteration/index.js.map +1 -1
  20. package/dist/runtime/query/iteration/phases/tool-review.d.ts.map +1 -1
  21. package/dist/runtime/query/iteration/phases/tool-review.js +4 -1
  22. package/dist/runtime/query/iteration/phases/tool-review.js.map +1 -1
  23. package/dist/runtime/query/iteration/step-context.d.ts +42 -0
  24. package/dist/runtime/query/iteration/step-context.d.ts.map +1 -1
  25. package/dist/runtime/query/iteration/step-context.js +114 -0
  26. package/dist/runtime/query/iteration/step-context.js.map +1 -1
  27. package/dist/runtime/query/iteration/step-shaping.d.ts +9 -1
  28. package/dist/runtime/query/iteration/step-shaping.d.ts.map +1 -1
  29. package/dist/runtime/query/iteration/step-shaping.js +21 -11
  30. package/dist/runtime/query/iteration/step-shaping.js.map +1 -1
  31. package/dist/runtime/query/steering.d.ts +13 -0
  32. package/dist/runtime/query/steering.d.ts.map +1 -1
  33. package/dist/runtime/query/steering.js +42 -0
  34. package/dist/runtime/query/steering.js.map +1 -1
  35. package/dist/scheduler/completion-inbox.js +1 -1
  36. package/dist/scheduler/completion-inbox.js.map +1 -1
  37. package/dist/tools/builtins/bash.d.ts +3 -5
  38. package/dist/tools/builtins/bash.d.ts.map +1 -1
  39. package/dist/tools/builtins/bash.js +14 -15
  40. package/dist/tools/builtins/bash.js.map +1 -1
  41. package/dist/tools/builtins/edit.js +1 -1
  42. package/dist/tools/builtins/edit.js.map +1 -1
  43. package/dist/tools/env-scrub.d.ts +8 -0
  44. package/dist/tools/env-scrub.d.ts.map +1 -1
  45. package/dist/tools/env-scrub.js +13 -0
  46. package/dist/tools/env-scrub.js.map +1 -1
  47. package/dist/turn/evidence-recall.d.ts +15 -3
  48. package/dist/turn/evidence-recall.d.ts.map +1 -1
  49. package/dist/turn/evidence-recall.js +116 -27
  50. package/dist/turn/evidence-recall.js.map +1 -1
  51. package/dist/types/message/notice-frame.d.ts +1 -1
  52. package/dist/types/message/notice-frame.d.ts.map +1 -1
  53. package/dist/types/message/notice-frame.js +1 -0
  54. package/dist/types/message/notice-frame.js.map +1 -1
  55. package/package.json +1 -1
  56. package/src/compaction/serializer.ts +29 -17
  57. package/src/compaction/verifier.ts +3 -2
  58. package/src/constants/compaction/index.ts +3 -1
  59. package/src/runtime/query/file-evidence-context.ts +8 -1
  60. package/src/runtime/query/iteration/index.ts +27 -13
  61. package/src/runtime/query/iteration/phases/tool-review.ts +3 -0
  62. package/src/runtime/query/iteration/step-context.ts +138 -0
  63. package/src/runtime/query/iteration/step-shaping.ts +39 -13
  64. package/src/runtime/query/steering.ts +51 -0
  65. package/src/scheduler/completion-inbox.ts +1 -1
  66. package/src/tools/builtins/bash.ts +14 -14
  67. package/src/tools/builtins/edit.ts +1 -1
  68. package/src/tools/env-scrub.ts +13 -0
  69. package/src/turn/evidence-recall.ts +125 -30
  70. package/src/types/message/notice-frame.ts +3 -1
@@ -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(
@@ -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,
@@ -91,3 +91,16 @@ export function scrubInheritedEnv(source: NodeJS.ProcessEnv = process.env): Scru
91
91
  dropped.sort()
92
92
  return { env, dropped }
93
93
  }
94
+
95
+ /**
96
+ * What the model is told, once, when shell commands run with credential-shaped
97
+ * variables removed. The count is enough: the model needs to know a missing
98
+ * credential is deliberate and who to ask, and the names are the user's data.
99
+ * The kernel sends this as a notice of its own after the tool batch; a command's
100
+ * result carries only what the command printed.
101
+ */
102
+ export function withheldEnvNoticeText(count: number): string {
103
+ if (count <= 0) return ''
104
+ const one = count === 1
105
+ return `A shell command that just ran had ${count} credential-shaped environment ${one ? 'variable' : 'variables'} removed from its environment, so ${one ? 'it reads' : 'they read'} as unset. When a failure looks like a missing credential, ask the user to provide ${one ? 'it' : 'one'}, because the user decides what a command may see.`
106
+ }