@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.
- package/CHANGELOG.md +20 -0
- package/dist/compaction/serializer.d.ts.map +1 -1
- package/dist/compaction/serializer.js +27 -15
- package/dist/compaction/serializer.js.map +1 -1
- package/dist/compaction/verifier.d.ts.map +1 -1
- package/dist/compaction/verifier.js +3 -2
- package/dist/compaction/verifier.js.map +1 -1
- package/dist/constants/compaction/index.d.ts +3 -1
- package/dist/constants/compaction/index.d.ts.map +1 -1
- package/dist/constants/compaction/index.js +3 -1
- package/dist/constants/compaction/index.js.map +1 -1
- package/dist/registry/tool/screen.d.ts.map +1 -1
- package/dist/registry/tool/screen.js +3 -1
- package/dist/registry/tool/screen.js.map +1 -1
- package/dist/runtime/query/executor/tool-call-admission.d.ts.map +1 -1
- package/dist/runtime/query/executor/tool-call-admission.js +6 -1
- package/dist/runtime/query/executor/tool-call-admission.js.map +1 -1
- package/dist/runtime/query/file-evidence-context.d.ts +4 -0
- package/dist/runtime/query/file-evidence-context.d.ts.map +1 -1
- package/dist/runtime/query/file-evidence-context.js +5 -1
- package/dist/runtime/query/file-evidence-context.js.map +1 -1
- package/dist/runtime/query/file-evidence-replay.js +7 -3
- package/dist/runtime/query/file-evidence-replay.js.map +1 -1
- package/dist/runtime/query/iteration/index.d.ts +2 -0
- package/dist/runtime/query/iteration/index.d.ts.map +1 -1
- package/dist/runtime/query/iteration/index.js +19 -11
- package/dist/runtime/query/iteration/index.js.map +1 -1
- package/dist/runtime/query/iteration/phases/tool-review.d.ts.map +1 -1
- package/dist/runtime/query/iteration/phases/tool-review.js +4 -1
- package/dist/runtime/query/iteration/phases/tool-review.js.map +1 -1
- package/dist/runtime/query/iteration/step-context.d.ts +42 -0
- package/dist/runtime/query/iteration/step-context.d.ts.map +1 -1
- package/dist/runtime/query/iteration/step-context.js +114 -0
- package/dist/runtime/query/iteration/step-context.js.map +1 -1
- package/dist/runtime/query/iteration/step-shaping.d.ts +9 -1
- package/dist/runtime/query/iteration/step-shaping.d.ts.map +1 -1
- package/dist/runtime/query/iteration/step-shaping.js +21 -11
- package/dist/runtime/query/iteration/step-shaping.js.map +1 -1
- package/dist/runtime/query/review-policy.d.ts.map +1 -1
- package/dist/runtime/query/review-policy.js +13 -1
- package/dist/runtime/query/review-policy.js.map +1 -1
- package/dist/runtime/query/steering.d.ts +13 -0
- package/dist/runtime/query/steering.d.ts.map +1 -1
- package/dist/runtime/query/steering.js +42 -0
- package/dist/runtime/query/steering.js.map +1 -1
- package/dist/scheduler/completion-inbox.js +1 -1
- package/dist/scheduler/completion-inbox.js.map +1 -1
- package/dist/tools/builtins/bash.d.ts +3 -5
- package/dist/tools/builtins/bash.d.ts.map +1 -1
- package/dist/tools/builtins/bash.js +14 -15
- package/dist/tools/builtins/bash.js.map +1 -1
- package/dist/tools/builtins/edit.js +1 -1
- package/dist/tools/builtins/edit.js.map +1 -1
- package/dist/tools/builtins/read-file.d.ts.map +1 -1
- package/dist/tools/builtins/read-file.js +19 -6
- package/dist/tools/builtins/read-file.js.map +1 -1
- package/dist/tools/builtins/read-render.d.ts +3 -0
- package/dist/tools/builtins/read-render.d.ts.map +1 -1
- package/dist/tools/builtins/read-render.js +40 -9
- package/dist/tools/builtins/read-render.js.map +1 -1
- package/dist/tools/env-scrub.d.ts +8 -0
- package/dist/tools/env-scrub.d.ts.map +1 -1
- package/dist/tools/env-scrub.js +13 -0
- package/dist/tools/env-scrub.js.map +1 -1
- package/dist/turn/evidence-recall.d.ts +15 -3
- package/dist/turn/evidence-recall.d.ts.map +1 -1
- package/dist/turn/evidence-recall.js +116 -27
- package/dist/turn/evidence-recall.js.map +1 -1
- package/dist/types/message/notice-frame.d.ts +1 -1
- package/dist/types/message/notice-frame.d.ts.map +1 -1
- package/dist/types/message/notice-frame.js +1 -0
- package/dist/types/message/notice-frame.js.map +1 -1
- package/package.json +1 -1
- package/src/compaction/serializer.ts +29 -17
- package/src/compaction/verifier.ts +3 -2
- package/src/constants/compaction/index.ts +3 -1
- package/src/registry/tool/screen.ts +3 -1
- package/src/runtime/query/executor/tool-call-admission.ts +5 -1
- package/src/runtime/query/file-evidence-context.ts +8 -1
- package/src/runtime/query/file-evidence-replay.ts +6 -3
- package/src/runtime/query/iteration/index.ts +27 -13
- package/src/runtime/query/iteration/phases/tool-review.ts +3 -0
- package/src/runtime/query/iteration/step-context.ts +138 -0
- package/src/runtime/query/iteration/step-shaping.ts +39 -13
- package/src/runtime/query/review-policy.ts +19 -1
- package/src/runtime/query/steering.ts +51 -0
- package/src/scheduler/completion-inbox.ts +1 -1
- package/src/tools/builtins/bash.ts +14 -14
- package/src/tools/builtins/edit.ts +1 -1
- package/src/tools/builtins/read-file.ts +18 -7
- package/src/tools/builtins/read-render.ts +40 -9
- package/src/tools/env-scrub.ts +13 -0
- package/src/turn/evidence-recall.ts +125 -30
- 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
|
-
|
|
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
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
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
|
-
(
|
|
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
|
|
806
|
-
//
|
|
807
|
-
//
|
|
808
|
-
//
|
|
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
|
-
*
|
|
908
|
-
* environment
|
|
909
|
-
*
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
206
|
-
|
|
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
|
}
|