@namzu/sdk 56.1.0 → 56.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/dist/compaction/context-breakdown.d.ts +1 -1
  3. package/dist/compaction/context-breakdown.d.ts.map +1 -1
  4. package/dist/compaction/context-breakdown.js +9 -2
  5. package/dist/compaction/context-breakdown.js.map +1 -1
  6. package/dist/compaction/verifier.d.ts.map +1 -1
  7. package/dist/compaction/verifier.js +3 -1
  8. package/dist/compaction/verifier.js.map +1 -1
  9. package/dist/public-runtime.d.ts +1 -1
  10. package/dist/public-runtime.d.ts.map +1 -1
  11. package/dist/public-runtime.js +1 -1
  12. package/dist/public-runtime.js.map +1 -1
  13. package/dist/runtime/query/executor.d.ts +8 -1
  14. package/dist/runtime/query/executor.d.ts.map +1 -1
  15. package/dist/runtime/query/executor.js +10 -2
  16. package/dist/runtime/query/executor.js.map +1 -1
  17. package/dist/runtime/query/file-evidence-context.d.ts +21 -4
  18. package/dist/runtime/query/file-evidence-context.d.ts.map +1 -1
  19. package/dist/runtime/query/file-evidence-context.js +70 -8
  20. package/dist/runtime/query/file-evidence-context.js.map +1 -1
  21. package/dist/runtime/query/index.d.ts.map +1 -1
  22. package/dist/runtime/query/index.js +2 -11
  23. package/dist/runtime/query/index.js.map +1 -1
  24. package/dist/runtime/query/iteration/index.d.ts +1 -2
  25. package/dist/runtime/query/iteration/index.d.ts.map +1 -1
  26. package/dist/runtime/query/iteration/index.js +30 -19
  27. package/dist/runtime/query/iteration/index.js.map +1 -1
  28. package/dist/runtime/query/iteration/recorded-context.d.ts +18 -0
  29. package/dist/runtime/query/iteration/recorded-context.d.ts.map +1 -0
  30. package/dist/runtime/query/iteration/recorded-context.js +37 -0
  31. package/dist/runtime/query/iteration/recorded-context.js.map +1 -0
  32. package/dist/runtime/query/iteration/step-context.d.ts +15 -51
  33. package/dist/runtime/query/iteration/step-context.d.ts.map +1 -1
  34. package/dist/runtime/query/iteration/step-context.js +24 -134
  35. package/dist/runtime/query/iteration/step-context.js.map +1 -1
  36. package/dist/runtime/query/iteration/step-shaping.d.ts +20 -8
  37. package/dist/runtime/query/iteration/step-shaping.d.ts.map +1 -1
  38. package/dist/runtime/query/iteration/step-shaping.js +52 -26
  39. package/dist/runtime/query/iteration/step-shaping.js.map +1 -1
  40. package/dist/scheduler/completion-inbox.d.ts +27 -0
  41. package/dist/scheduler/completion-inbox.d.ts.map +1 -1
  42. package/dist/scheduler/completion-inbox.js +88 -21
  43. package/dist/scheduler/completion-inbox.js.map +1 -1
  44. package/dist/store/session-index/fts.d.ts.map +1 -1
  45. package/dist/store/session-index/fts.js +6 -0
  46. package/dist/store/session-index/fts.js.map +1 -1
  47. package/dist/test-support/file-evidence-held.d.ts +11 -0
  48. package/dist/test-support/file-evidence-held.d.ts.map +1 -0
  49. package/dist/test-support/file-evidence-held.js +28 -0
  50. package/dist/test-support/file-evidence-held.js.map +1 -0
  51. package/dist/test-support/request-only.d.ts +10 -0
  52. package/dist/test-support/request-only.d.ts.map +1 -0
  53. package/dist/test-support/request-only.js +15 -0
  54. package/dist/test-support/request-only.js.map +1 -0
  55. package/dist/tools/untrusted-envelope.d.ts +7 -0
  56. package/dist/tools/untrusted-envelope.d.ts.map +1 -1
  57. package/dist/tools/untrusted-envelope.js +3 -1
  58. package/dist/tools/untrusted-envelope.js.map +1 -1
  59. package/dist/turn/evidence-recall.d.ts +19 -8
  60. package/dist/turn/evidence-recall.d.ts.map +1 -1
  61. package/dist/turn/evidence-recall.js +134 -53
  62. package/dist/turn/evidence-recall.js.map +1 -1
  63. package/dist/turn/memory-recall.d.ts +18 -0
  64. package/dist/turn/memory-recall.d.ts.map +1 -1
  65. package/dist/turn/memory-recall.js +43 -4
  66. package/dist/turn/memory-recall.js.map +1 -1
  67. package/dist/types/message/index.d.ts +46 -1
  68. package/dist/types/message/index.d.ts.map +1 -1
  69. package/dist/types/message/index.js +78 -1
  70. package/dist/types/message/index.js.map +1 -1
  71. package/dist/types/session/prepare-step.d.ts +24 -0
  72. package/dist/types/session/prepare-step.d.ts.map +1 -1
  73. package/package.json +1 -1
  74. package/src/compaction/context-breakdown.ts +11 -3
  75. package/src/compaction/verifier.ts +3 -1
  76. package/src/public-runtime.ts +7 -0
  77. package/src/runtime/query/executor.ts +20 -2
  78. package/src/runtime/query/file-evidence-context.ts +79 -11
  79. package/src/runtime/query/index.ts +2 -10
  80. package/src/runtime/query/iteration/index.ts +32 -26
  81. package/src/runtime/query/iteration/recorded-context.ts +48 -0
  82. package/src/runtime/query/iteration/step-context.ts +27 -155
  83. package/src/runtime/query/iteration/step-shaping.ts +64 -40
  84. package/src/scheduler/completion-inbox.ts +87 -20
  85. package/src/store/session-index/fts.ts +5 -0
  86. package/src/test-support/file-evidence-held.ts +27 -0
  87. package/src/test-support/request-only.ts +19 -0
  88. package/src/tools/untrusted-envelope.ts +10 -1
  89. package/src/turn/evidence-recall.ts +189 -58
  90. package/src/turn/memory-recall.ts +59 -5
  91. package/src/types/message/index.ts +108 -1
  92. package/src/types/session/prepare-step.ts +26 -0
@@ -28,7 +28,6 @@ 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'
32
31
  import type { CostInfo, TokenUsage } from '../../../types/common/index.js'
33
32
  import { NamzuError } from '../../../types/errors/index.js'
34
33
  import type { MessageId, SessionId, TurnId } from '../../../types/ids/index.js'
@@ -58,7 +57,6 @@ import { createCallbackInference } from '../callback-inference.js'
58
57
  import { SandboxAcquisitionStopped } from '../deferred-sandbox.js'
59
58
  import { deliverDurableInbound } from '../durable-inbound.js'
60
59
  import type { ToolCallOutcome } from '../executor.js'
61
- import { FILE_EVIDENCE_NONE } from '../file-evidence-context.js'
62
60
  import { projectObservationContext } from '../observation-context.js'
63
61
  import { applyLifecycleHookResults } from '../plugin-hooks.js'
64
62
  import {
@@ -103,17 +101,16 @@ import {
103
101
  type TextOnlyModelRefusal,
104
102
  streamWithProviderRejectedImageRecovery,
105
103
  } from './provider-rejected-image.js'
104
+ import { appendRecordedContext } from './recorded-context.js'
106
105
  import { type MediaRemoval, RequestRepair } from './request-repair.js'
107
- import { TurnContextCarry } from './step-context.js'
106
+ import { placeRequestContext } from './step-context.js'
108
107
  import {
109
- FILE_EVIDENCE_KEY,
110
108
  type StepShaping,
111
- appendWorkContext,
112
109
  beforeStep,
113
110
  prepareStep,
114
111
  selectContextModel,
115
112
  stepContextMessage,
116
- workContextBlocks,
113
+ workContext,
117
114
  } from './step-shaping.js'
118
115
  import { streamProviderTurn } from './stream-turn.js'
119
116
 
@@ -194,8 +191,7 @@ export class IterationOrchestrator {
194
191
  * one process must not suppress each other's first envelope.
195
192
  */
196
193
  private lastEnvelopeKey: string | undefined
197
- /** Where this turn's request-only data first went, so it stays there. */
198
- private readonly turnContext = new TurnContextCarry()
194
+ /** The previous request's context, so a pre-call hook is told what changed. */
199
195
  private previousRequestContext: RequestContextSnapshot | undefined
200
196
 
201
197
  private projectObservations(messages: Message[]): Message[] {
@@ -838,6 +834,11 @@ export class IterationOrchestrator {
838
834
  'limit-finalization',
839
835
  )
840
836
  : undefined
837
+ // What the step asked to have kept in the conversation joins the
838
+ // history now, after its last message, so this request and every
839
+ // later one begin with it. Nothing is inserted ahead of a message
840
+ // that is already recorded.
841
+ appendRecordedContext(recorder, step.recordedContext, this.ctx.log)
841
842
  const baseMessages = recorder.messages
842
843
 
843
844
  // Step guidance is appended to the REQUEST, never pushed onto
@@ -911,24 +912,30 @@ export class IterationOrchestrator {
911
912
  DEFAULT_MAX_REQUEST_RICH_CONTENT_BYTES,
912
913
  { imagesReadable: this.modelReadsImages(stepModel) },
913
914
  )
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.
915
+ // Request-only data (the context contributions, notes about the
916
+ // search, the work context) goes last on every call, after the
917
+ // whole history, so the history a caching driver reads never
918
+ // changes shape from one call to the next. What a step wants
919
+ // kept is recorded in the history above instead. The short
920
+ // reminders and notices follow it.
922
921
  const messages = [...projected]
923
- this.turnContext.place(
922
+ // What the loop learns about files and finished work holds until it
923
+ // changes, so it is recorded, after the history as it stands, and
924
+ // carried in this request where it will sit in the next one's history.
925
+ const work = workContext(shaping, projected, iterationNum, step)
926
+ const recordedNow = appendRecordedContext(recorder, work.recorded, this.ctx.log)
927
+ placeRequestContext(
924
928
  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) },
929
+ recordedNow,
930
+ // The working-memory block and the policy notice already end the
931
+ // request; the entries just recorded belong ahead of them.
932
+ (workingMemory.context ? 1 : 0) + (policyNotice ? 1 : 0),
933
+ [
934
+ ...[contextText, step.context]
935
+ .filter((text): text is string => Boolean(text))
936
+ .map(stepContextMessage),
937
+ ...work.requestOnly,
938
+ ],
932
939
  )
933
940
  if (closingDirective) messages.push(closingDirective)
934
941
  await this.reportUnsupportedToolResults(messages)
@@ -2896,9 +2903,8 @@ export class IterationOrchestrator {
2896
2903
  this.ctx.turnConfig.maxRequestRichContentBytes ?? DEFAULT_MAX_REQUEST_RICH_CONTENT_BYTES,
2897
2904
  { imagesReadable: this.modelReadsImages(model) },
2898
2905
  )
2899
- appendWorkContext(this.stepShaping(), finalMessages, this.steps.length + 1, { model })
2900
2906
  // The closing directive is the last thing the model reads: after
2901
- // the working memory and any work context above.
2907
+ // the working memory above.
2902
2908
  finalMessages.push(
2903
2909
  createRuntimeContextMessage(
2904
2910
  `[SYSTEM] Turn is ending due to ${reason}. ${CLOSING_RESPONSE_GUIDANCE}`,
@@ -0,0 +1,48 @@
1
+ import { NAMZU } from '../../../constants/telemetry/index.js'
2
+ import type { TurnRecorder } from '../../../manager/session/turn-recorder.js'
3
+ import {
4
+ type Message,
5
+ createTurnContextEntry,
6
+ isTurnContextKey,
7
+ turnContextEntries,
8
+ } from '../../../types/message/index.js'
9
+ import type { RecordedContextItem } from '../../../types/session/index.js'
10
+ import type { Logger } from '../../../utils/logger.js'
11
+
12
+ /**
13
+ * Append what a step asked to have recorded, after whatever the history
14
+ * ends with.
15
+ *
16
+ * Only at the end: an entry slipped in ahead of the operator's message would
17
+ * put the live array out of log order, and the recorder would then write a
18
+ * whole compaction record to re-base it on every turn. An item adds nothing
19
+ * when its text is empty or equals the last entry under its key, so a step
20
+ * that offers the same text on every call records it once. Returns the
21
+ * entries it added, so a request built before they were added can carry them
22
+ * at the place they will hold.
23
+ */
24
+ export function appendRecordedContext(
25
+ recorder: Pick<TurnRecorder, 'messages' | 'pushMessage' | 'turnId'>,
26
+ items: readonly RecordedContextItem[] | undefined,
27
+ log: Pick<Logger, 'warn'>,
28
+ at: Date = new Date(),
29
+ ): Message[] {
30
+ const appended: Message[] = []
31
+ for (const item of items ?? []) {
32
+ const text = item.text.trim()
33
+ if (text.length === 0) continue
34
+ if (!isTurnContextKey(item.key)) {
35
+ log.warn('A step recorded context under a key that is not a name; it was skipped', {
36
+ [NAMZU.TURN_ID]: recorder.turnId,
37
+ 'namzu.runtime.key': String(item.key).slice(0, 80),
38
+ })
39
+ continue
40
+ }
41
+ const earlier = turnContextEntries(recorder.messages, item.key)
42
+ if (earlier.at(-1) === text) continue
43
+ const entry = createTurnContextEntry(item.key, text, at, { later: earlier.length > 0 })
44
+ recorder.pushMessage(entry)
45
+ appended.push(entry)
46
+ }
47
+ return appended
48
+ }
@@ -1,6 +1,5 @@
1
1
  import { createRuntimeContextMessage } from '../../../types/message/index.js'
2
2
  import type { Message } from '../../../types/message/index.js'
3
- import { isOperatorUserMessage } from '../steering.js'
4
3
 
5
4
  /**
6
5
  * Request-only context, as the model reads it: a runtime-context user
@@ -18,167 +17,40 @@ export function stepContextMessage(content: string) {
18
17
  )
19
18
  }
20
19
 
21
- /**
22
- * Put `blocks` in front of the operator's new message when that message is
23
- * what the model is about to answer, so the request is the last thing it
24
- * reads and the longest data comes before it. Only the reminders and other
25
- * request-only context may follow that message: once the turn has an
26
- * assistant or tool message after it, the blocks stay at the end of the
27
- * history, where a caching driver ends its breakpoint before them, so the
28
- * tool loop of a long turn is not re-read at full price on every step.
29
- *
30
- * Reminders and notices that follow the operator's message stay where they
31
- * are: they are short, and the edge after the request is where they are read.
32
- */
33
- export function placeBeforeLatestOperatorMessage(history: Message[], blocks: readonly Message[]) {
34
- if (blocks.length === 0) return
35
- let at = history.length
36
- for (let i = history.length - 1; i >= 0; i--) {
37
- const message = history[i] as Message
38
- if (isOperatorUserMessage(message)) {
39
- at = i
40
- break
41
- }
42
- // A reply or a tool result after the message: the model has moved on.
43
- if (message.role === 'assistant' || message.role === 'tool') break
44
- }
45
- history.splice(at, 0, ...blocks)
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
20
  function textOf(message: Message): string {
55
21
  return typeof message.content === 'string' ? message.content : JSON.stringify(message.content)
56
22
  }
57
23
 
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
24
  /**
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.
25
+ * Put a request's own data into it: after everything the history holds, in
26
+ * the order offered, each distinct text once.
92
27
  *
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.
28
+ * Request-only data is what the model needs for this request alone (a note
29
+ * that something is unavailable, the work still running). It is re-placed on
30
+ * every call and always last, so the history before it never changes shape
31
+ * from one call to the next and a caching driver, which ends its breakpoint
32
+ * before the first such block, covers the whole history on every call. What
33
+ * the model should keep reading in later requests is recorded as turn context
34
+ * instead (`PrepareStepResult.recordedContext`), and the loop records what it
35
+ * learns about files and finished work the same way.
101
36
  *
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.
37
+ * `request` is the projected history. `recorded` are entries the loop has just
38
+ * recorded: they join the history at the end of this call, so they go ahead
39
+ * of the first request-only block already in `request` (`tail` of them, at the
40
+ * end), which is where the next request will find them.
104
41
  */
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)
42
+ export function placeRequestContext(
43
+ request: Message[],
44
+ recorded: readonly Message[],
45
+ tail: number,
46
+ blocks: readonly Message[],
47
+ ): void {
48
+ if (recorded.length > 0) request.splice(request.length - tail, 0, ...recorded)
49
+ const seen = new Set<string>()
50
+ for (const message of blocks) {
51
+ const text = textOf(message)
52
+ if (seen.has(text)) continue
53
+ seen.add(text)
54
+ request.push(message)
183
55
  }
184
56
  }
@@ -1,30 +1,30 @@
1
1
  import { resolveContextWindow } from '../../../compaction/context-window.js'
2
- import { estimateMessageTokens } from '../../../compaction/token-estimate.js'
2
+ import { estimateMessageTokens, estimateTextTokens } from '../../../compaction/token-estimate.js'
3
3
  import { NAMZU } from '../../../constants/telemetry/index.js'
4
4
  import { renderSkillsSection } from '../../../persona/assembler.js'
5
+ import { recordedOwnedTaskIds } from '../../../scheduler/completion-inbox.js'
5
6
  import { PreparationContextError } from '../../../turn/preparation-context-error.js'
6
7
  import {
7
8
  type Message,
8
9
  type UserMessage,
9
10
  createSystemMessage,
11
+ turnContextEntries,
10
12
  } from '../../../types/message/index.js'
11
13
  import type { ToolChoice } from '../../../types/provider/chat.js'
12
14
  import type {
13
15
  PrepareStepContext,
14
16
  PrepareStepResult,
17
+ RecordedContextItem,
15
18
  StepResult,
16
19
  StepVeto,
17
20
  } from '../../../types/session/index.js'
18
21
  import type { Skill } from '../../../types/skills/index.js'
19
22
  import { toErrorMessage } from '../../../utils/error.js'
20
23
  import { createCallbackInference } from '../callback-inference.js'
24
+ import { recordFileEvidence } from '../file-evidence-context.js'
21
25
  import { measureContext } from './phases/compaction.js'
22
26
  import type { IterationContext } from './phases/index.js'
23
- import {
24
- type ReplaceableBlock,
25
- placeBeforeLatestOperatorMessage,
26
- stepContextMessage,
27
- } from './step-context.js'
27
+ import { stepContextMessage } from './step-context.js'
28
28
 
29
29
  /**
30
30
  * How the next request is shaped: admission, preparation, and the context
@@ -48,57 +48,68 @@ export interface StepShaping {
48
48
 
49
49
  export { stepContextMessage }
50
50
 
51
- /** Derived after request projection; never accumulates in canonical history or replaces operator intent. */
52
- export function appendWorkContext(
53
- shaping: StepShaping,
54
- messages: Message[],
55
- stepNumber: number,
56
- prepared: PrepareStepResult,
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. */
51
+ /** The names the two kinds of recorded work context are filed under. */
67
52
  export const OWNED_WORK_KEY = 'owned-work'
68
53
  export const FILE_EVIDENCE_KEY = 'file-evidence'
69
54
 
55
+ /** What the work context says for one request. */
56
+ export interface WorkContext {
57
+ /** Facts that do not change once true, to be kept in the conversation. */
58
+ readonly recorded: RecordedContextItem[]
59
+ /** Facts about now, shown with this request only. */
60
+ readonly requestOnly: Message[]
61
+ }
62
+
70
63
  /**
71
- * The work-context blocks for a request whose projected messages are
72
- * `messages`, each under the name of what it reports.
64
+ * The work context for a request whose projected messages are `messages`.
65
+ *
66
+ * Two kinds of fact, each where it belongs. What holds until something
67
+ * changes it, or never changes, is recorded: the file entries that are new or
68
+ * changed since the last record (with the paths whose entry no longer holds),
69
+ * and each owned task once it has finished. What is true only now, the tasks
70
+ * still running, goes with this request alone. Each text is admitted whole or
71
+ * not at all, within the room the request has left.
73
72
  */
74
- export function workContextBlocks(
73
+ export function workContext(
75
74
  shaping: StepShaping,
76
75
  messages: readonly Message[],
77
76
  stepNumber: number,
78
77
  prepared: PrepareStepResult,
79
- ): ReplaceableBlock[] {
78
+ ): WorkContext {
80
79
  const { ctx } = shaping
80
+ const held = (key: string) => turnContextEntries(ctx.recorder.messages, key)
81
+ const inbox = ctx.completionInbox
81
82
 
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[] = []
83
+ const contributions: { content: string | undefined; key?: string }[] = [
84
+ { content: inbox?.describeRunningWork() },
85
+ {
86
+ content: inbox?.describeSettledWork(recordedOwnedTaskIds(held(OWNED_WORK_KEY))),
87
+ key: OWNED_WORK_KEY,
88
+ },
89
+ {
90
+ content: recordFileEvidence(
91
+ ctx.toolExecutor.fileEvidenceEntries(messages),
92
+ held(FILE_EVIDENCE_KEY),
93
+ ),
94
+ key: FILE_EVIDENCE_KEY,
95
+ },
96
+ ]
97
+ const present = contributions.filter((entry) => Boolean(entry.content))
98
+ const work: WorkContext = { recorded: [], requestOnly: [] }
99
+ if (present.length === 0) return work
90
100
  let room = stepContext(shaping, stepNumber, prepared).contextBudget?.remainingTokens ?? 0
91
101
  // Leave room for the actual task; admit whole contributions, never dangling partial references.
92
- if (room < 1_500) return []
93
- for (const [key, content] of contributions) {
102
+ if (room < 1_500) return work
103
+ for (const { content, key } of present) {
94
104
  if (!content || content.length > 8_000) continue
95
105
  const message = stepContextMessage(content)
96
106
  const tokens = estimateMessageTokens(message)
97
107
  if (tokens > Math.min(2_000, room - 1_000)) continue
98
- placed.push({ key, message })
108
+ if (key) work.recorded.push({ key, text: content })
109
+ else work.requestOnly.push(message)
99
110
  room -= tokens
100
111
  }
101
- return placed
112
+ return work
102
113
  }
103
114
 
104
115
  export function stepContext(
@@ -122,7 +133,8 @@ export function stepContext(
122
133
  const preamble = [prepared.system, skills].filter(Boolean).join('\n\n')
123
134
  const preparedTokens =
124
135
  (preamble ? estimateMessageTokens(createSystemMessage(preamble)) : 0) +
125
- (prepared.context ? estimateMessageTokens(stepContextMessage(prepared.context)) : 0)
136
+ (prepared.context ? estimateMessageTokens(stepContextMessage(prepared.context)) : 0) +
137
+ (prepared.recordedContext ?? []).reduce((sum, item) => sum + estimateTextTokens(item.text), 0)
126
138
  const responseReserve = Math.min(
127
139
  prepared.maxResponseTokens ?? ctx.turnConfig.maxResponseTokens ?? Math.floor(window.tokens / 4),
128
140
  Math.floor(window.tokens / 4),
@@ -180,6 +192,7 @@ export async function prepareStep(
180
192
  model?: string
181
193
  system?: string
182
194
  context?: string
195
+ recordedContext?: readonly RecordedContextItem[]
183
196
  skills?: readonly Skill[]
184
197
  temperature?: number
185
198
  maxResponseTokens?: number
@@ -206,7 +219,16 @@ export async function prepareStep(
206
219
  ...stepContext(shaping, stepNumber, result),
207
220
  generateText: inference.generateText,
208
221
  })
209
- if (decided) result = { ...result, ...decided }
222
+ if (decided) {
223
+ // Recorded context adds up across stages: each stage files its own
224
+ // keys, and one stage must not erase what an earlier one recorded.
225
+ const recorded = [...(result.recordedContext ?? []), ...(decided.recordedContext ?? [])]
226
+ result = {
227
+ ...result,
228
+ ...decided,
229
+ ...(recorded.length > 0 ? { recordedContext: recorded } : {}),
230
+ }
231
+ }
210
232
  await selectContextModel(shaping, result.model ?? ctx.turnConfig.model)
211
233
  } catch (err) {
212
234
  // Skipped, and the rest still run: one broken concern must
@@ -242,6 +264,7 @@ export async function prepareStep(
242
264
  model?: string
243
265
  system?: string
244
266
  context?: string
267
+ recordedContext?: readonly RecordedContextItem[]
245
268
  skills?: readonly Skill[]
246
269
  temperature?: number
247
270
  maxResponseTokens?: number
@@ -279,6 +302,7 @@ export async function prepareStep(
279
302
  if (result.model !== undefined) prepared.model = result.model
280
303
  if (result.system !== undefined) prepared.system = result.system
281
304
  if (result.context !== undefined) prepared.context = result.context
305
+ if (result.recordedContext !== undefined) prepared.recordedContext = result.recordedContext
282
306
  if (result.skills !== undefined) prepared.skills = result.skills
283
307
  if (result.temperature !== undefined) prepared.temperature = result.temperature
284
308
  if (result.maxResponseTokens !== undefined) {