@inflexa-ai/harness 0.41.1 → 0.42.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 (88) hide show
  1. package/dist/agents/conversation-agent.js +3 -0
  2. package/dist/agents/report-session-agent.d.ts +2 -2
  3. package/dist/agents/report-session-agent.js +4 -0
  4. package/dist/app/chat-turn.d.ts +0 -4
  5. package/dist/app/chat-turn.js +39 -25
  6. package/dist/app/message-assembly.d.ts +2 -2
  7. package/dist/app/message-assembly.js +1 -1
  8. package/dist/contracts/chat-events.d.ts +12 -5
  9. package/dist/contracts/chat-events.js +3 -3
  10. package/dist/contracts/chat-frame.d.ts +40 -0
  11. package/dist/contracts/chat-frame.js +166 -0
  12. package/dist/contracts/chat-parts.d.ts +15 -3
  13. package/dist/contracts/chat-parts.js +2 -2
  14. package/dist/contracts/index.d.ts +6 -4
  15. package/dist/contracts/index.js +1 -0
  16. package/dist/contracts/message.d.ts +7 -5
  17. package/dist/contracts/message.js +1 -1
  18. package/dist/contracts/part-registry.d.ts +6 -6
  19. package/dist/contracts/report-blocks.d.ts +10 -10
  20. package/dist/contracts/report-reference.d.ts +4 -4
  21. package/dist/contracts/schemas/chat-events.d.ts +2 -2
  22. package/dist/contracts/schemas/chat-events.js +2 -2
  23. package/dist/contracts/schemas/chat-parts.d.ts +34 -2
  24. package/dist/contracts/schemas/chat-parts.js +9 -3
  25. package/dist/contracts/schemas/index.d.ts +2 -2
  26. package/dist/contracts/schemas/index.js +2 -2
  27. package/dist/execution/run-event-parts.d.ts +4 -4
  28. package/dist/execution/run-event-parts.js +0 -0
  29. package/dist/execution/run-event-stream.d.ts +2 -2
  30. package/dist/index.d.ts +9 -6
  31. package/dist/index.js +3 -2
  32. package/dist/lib/page-capture.d.ts +27 -9
  33. package/dist/lib/page-capture.js +81 -33
  34. package/dist/loop/compaction.d.ts +8 -3
  35. package/dist/loop/run-agent.d.ts +11 -0
  36. package/dist/loop/run-agent.js +62 -35
  37. package/dist/loop/types.d.ts +3 -0
  38. package/dist/memory/ai-sdk-message-storage.d.ts +18 -3
  39. package/dist/memory/ai-sdk-message-storage.js +23 -1
  40. package/dist/memory/conversation-display-recorder.d.ts +7 -1
  41. package/dist/memory/conversation-display-recorder.js +59 -119
  42. package/dist/memory/conversation-display-replay.d.ts +3 -3
  43. package/dist/memory/conversation-display-replay.js +7 -21
  44. package/dist/memory/conversation-display-storage.d.ts +10 -10
  45. package/dist/memory/conversation-display-storage.js +9 -8
  46. package/dist/memory/conversation-view.d.ts +5 -4
  47. package/dist/memory/conversation-view.js +14 -18
  48. package/dist/memory/count-tokens.js +1 -0
  49. package/dist/memory/message-backfill.d.ts +8 -2
  50. package/dist/memory/thread-store.d.ts +9 -2
  51. package/dist/memory/thread-store.js +8 -2
  52. package/dist/prompts/compaction.d.ts +2 -2
  53. package/dist/prompts/compaction.js +2 -2
  54. package/dist/prompts/conversation.d.ts +1 -1
  55. package/dist/prompts/conversation.js +12 -0
  56. package/dist/prompts/report-session.d.ts +1 -1
  57. package/dist/prompts/report-session.js +40 -28
  58. package/dist/report-model/authoring-grammar.d.ts +4 -4
  59. package/dist/report-render/views/block-mark.d.ts +8 -0
  60. package/dist/report-render/views/block-mark.js +10 -0
  61. package/dist/report-render/views/chart-view.js +2 -1
  62. package/dist/report-render/views/prose.js +10 -6
  63. package/dist/report-render/views/values.js +5 -4
  64. package/dist/runtime/boot.d.ts +2 -2
  65. package/dist/runtime/boot.js +3 -3
  66. package/dist/state/init.d.ts +7 -6
  67. package/dist/state/init.js +51 -672
  68. package/dist/state/migrations/20260927120000_baseline.d.ts +11 -0
  69. package/dist/state/migrations/20260927120000_baseline.js +675 -0
  70. package/dist/state/migrations/20260927180000_thread_title_set_by_user.d.ts +6 -0
  71. package/dist/state/migrations/20260927180000_thread_title_set_by_user.js +10 -0
  72. package/dist/state/migrations/index.d.ts +10 -0
  73. package/dist/state/migrations/index.js +15 -0
  74. package/dist/tools/display/show-user.js +6 -4
  75. package/dist/tools/report-authoring/authoring-tools.d.ts +16 -5
  76. package/dist/tools/report-authoring/authoring-tools.js +52 -24
  77. package/dist/tools/report-session/examine-page.d.ts +29 -14
  78. package/dist/tools/report-session/examine-page.js +40 -16
  79. package/dist/tools/report-session/list-artifacts.d.ts +23 -10
  80. package/dist/tools/report-session/list-artifacts.js +51 -31
  81. package/dist/tools/report-session/page-faults.d.ts +29 -0
  82. package/dist/tools/report-session/page-faults.js +48 -0
  83. package/dist/tools/report-session/preview-report.d.ts +9 -2
  84. package/dist/tools/report-session/preview-report.js +13 -3
  85. package/dist/tools/report-session/record-version.d.ts +5 -1
  86. package/dist/tools/report-session/record-version.js +4 -3
  87. package/dist/tools/workspace/show-file.js +4 -3
  88. package/package.json +2 -1
@@ -5,12 +5,16 @@
5
5
  * readiness signal of the page, and gives back base64 screenshots. The eyes tool of a report session reads
6
6
  * this module.
7
7
  *
8
+ * The layout resolves at the CSS width of a reader, and the picture renders at half the device scale. A
9
+ * picture costs tokens in proportion to its area, thus the half scale lets one look hold a whole report.
10
+ *
8
11
  * A short page captures as one full-page shot. A tall page captures as consecutive vertical slices, because
9
- * one tall picture dies twice on the provider path: a picture past the hard dimension cap rejects the whole
10
- * request, and a legal tall picture downscales to about 1568 pixels on the long side, which compresses the
11
- * text past legibility. A slice of about two window heights survives both. The slice budget bounds what one
12
- * look costs, and a page past the budget truncates with the captured and the total pixels on the coverage,
13
- * thus the truncation is never silent.
12
+ * the model reads a picture whole only up to a limit on the long edge, and a taller picture downscales or
13
+ * refuses the request. The slice budget bounds what one look costs, and a page past the budget truncates
14
+ * with the captured and the total pixels on the coverage, thus the truncation is never silent.
15
+ *
16
+ * A capture can also take one block alone. The renderer marks each block with its id, and the capture clips
17
+ * the union box of the marked elements with a margin. A page that holds no such mark gives no picture.
14
18
  *
15
19
  * The readiness contract is the reason for one shared body. The renderer emits the event name and the
16
20
  * sentinel name, and `report-render/page.ts` owns both constants together with the two budgets. This module
@@ -26,6 +30,7 @@
26
30
  */
27
31
  import { withPage } from "./chrome.js";
28
32
  import { PAGE_NAV_TIMEOUT_MS, PAGE_READY_TIMEOUT_MS, THEME_READY_EVENT, THEME_READY_SENTINEL } from "../report-render/page.js";
33
+ import { BLOCK_ID_ATTRIBUTE } from "../report-render/views/block-mark.js";
29
34
  /** The budget of the optional selector wait. The wait is best-effort, thus a miss captures the page anyway. */
30
35
  const SELECTOR_TIMEOUT_MS = 5_000;
31
36
  /**
@@ -37,22 +42,22 @@ const SELECTOR_TIMEOUT_MS = 5_000;
37
42
  const VIEWPORT_WIDTH = 1440;
38
43
  const VIEWPORT_HEIGHT = 900;
39
44
  /**
40
- * The height of one capture slice, in CSS pixels, and the bound of a single-shot page.
41
- *
42
- * The provider downscales every picture to about 1568 pixels on the long side before the model reads it. Two
43
- * window heights survive that downscale with readable text, and a taller picture reaches the model compressed
44
- * past that. Thus a page at this height or under captures as one full-page shot, and a taller page captures
45
- * as slices of this height.
45
+ * The device scale of one capture. The layout keeps the CSS width of a reader, and the picture holds half
46
+ * the pixels on each side. A picture costs about one token for each 750 pixels of its area.
46
47
  */
47
- const TILE_HEIGHT_PX = VIEWPORT_HEIGHT * 2;
48
+ const DEVICE_SCALE_FACTOR = 0.5;
48
49
  /**
49
- * The most slices of one capture.
50
- *
51
- * Each slice is one picture on the tool result, and the budget bounds what one look costs the context. A page
52
- * taller than the budget covers truncates, and the coverage carries the captured and the total pixels, thus
53
- * the truncation is never silent.
50
+ * The height of one slice and the bound of a single-shot page, in CSS pixels. At the half scale a slice is
51
+ * 720 by 2000 pixels, under the long edge of 2576 pixels that the model reads with no downscale.
54
52
  */
55
- const MAX_TILES = 6;
53
+ const TILE_HEIGHT_PX = 4_000;
54
+ /**
55
+ * The most slices of one capture: 20,000 CSS pixels, about 9,600 tokens, thus a report of 16,000 pixels
56
+ * fits whole. A taller page truncates, and the coverage carries the captured and the total pixels.
57
+ */
58
+ const MAX_TILES = 5;
59
+ /** The margin around the box of one block, in CSS pixels. Content that overflows its box stays in the picture. */
60
+ const BLOCK_MARGIN_PX = 16;
56
61
  /**
57
62
  * The body of the readiness wait, in the browser context. The sentinel arm resolves a page that dispatched
58
63
  * the event before this wait registered, thus a plain listener never blocks forever. The timer arm bounds a
@@ -94,26 +99,67 @@ async function measureTotalHeight(page) {
94
99
  return typeof measured === "number" && Number.isFinite(measured) ? measured : 0;
95
100
  }
96
101
  /**
97
- * Capture the slices of one tall page, in document order. Each slice clips {@link TILE_HEIGHT_PX} rows at the
98
- * reader width, and the last slice ends at the document height or at the budget, whichever comes first.
102
+ * Capture the rows of one area as slices, in document order. Each slice clips {@link TILE_HEIGHT_PX} rows,
103
+ * and the last slice ends at the area or at the budget, whichever comes first.
99
104
  */
100
- async function captureTiles(page, totalPx) {
101
- const tileCount = Math.min(Math.ceil(totalPx / TILE_HEIGHT_PX), MAX_TILES);
105
+ async function captureSlices(page, area) {
106
+ const heightPx = area.toY - area.fromY;
107
+ const tileCount = Math.min(Math.ceil(heightPx / TILE_HEIGHT_PX), MAX_TILES);
102
108
  const screenshots = [];
103
109
  for (let index = 0; index < tileCount; index++) {
104
- const fromY = index * TILE_HEIGHT_PX;
105
- const toY = Math.min(fromY + TILE_HEIGHT_PX, totalPx);
106
- const clip = { x: 0, y: fromY, width: VIEWPORT_WIDTH, height: toY - fromY };
110
+ const fromY = area.fromY + index * TILE_HEIGHT_PX;
111
+ const toY = Math.min(fromY + TILE_HEIGHT_PX, area.toY);
112
+ const clip = { x: area.x, y: fromY, width: area.width, height: toY - fromY };
107
113
  screenshots.push({ base64: toBase64(await page.screenshot({ encoding: "base64", clip })), range: { fromY, toY } });
108
114
  }
109
- const capturedPx = Math.min(tileCount * TILE_HEIGHT_PX, totalPx);
110
- return { screenshots, coverage: { kind: "tiled", capturedPx, totalPx } };
115
+ return { screenshots, capturedPx: Math.min(tileCount * TILE_HEIGHT_PX, heightPx) };
116
+ }
117
+ /**
118
+ * The body of the block measure, in the browser context: the union box of the marked elements, with the
119
+ * margin, or `null`. The body compares each attribute value, thus an id never becomes part of a selector.
120
+ */
121
+ function measureBlock(attribute, blockId, margin) {
122
+ let left = Number.POSITIVE_INFINITY;
123
+ let top = Number.POSITIVE_INFINITY;
124
+ let right = Number.NEGATIVE_INFINITY;
125
+ let bottom = Number.NEGATIVE_INFINITY;
126
+ for (const node of Array.from(document.querySelectorAll(`[${attribute}]`))) {
127
+ if (node.getAttribute(attribute) !== blockId)
128
+ continue;
129
+ const rect = node.getBoundingClientRect();
130
+ left = Math.min(left, rect.left + window.scrollX);
131
+ top = Math.min(top, rect.top + window.scrollY);
132
+ right = Math.max(right, rect.right + window.scrollX);
133
+ bottom = Math.max(bottom, rect.bottom + window.scrollY);
134
+ }
135
+ if (left === Number.POSITIVE_INFINITY) {
136
+ return null;
137
+ }
138
+ const root = document.documentElement;
139
+ const x = Math.max(0, Math.floor(left - margin));
140
+ const fromY = Math.max(0, Math.floor(top - margin));
141
+ const toX = Math.min(root.scrollWidth, Math.ceil(right + margin));
142
+ const toY = Math.min(root.scrollHeight, Math.ceil(bottom + margin));
143
+ // A block of no height still gives a strip, thus the picture shows the empty place of the block.
144
+ return { x, width: Math.max(1, toX - x), fromY, toY: Math.max(fromY + 1, toY) };
145
+ }
146
+ /**
147
+ * Take the pictures of one block. A refused bitmap propagates, because the window shows the top of the page
148
+ * and not the block.
149
+ */
150
+ async function captureBlock(page, blockId) {
151
+ const area = await page.evaluate(measureBlock, BLOCK_ID_ATTRIBUTE, blockId, BLOCK_MARGIN_PX);
152
+ if (area === null) {
153
+ return { screenshots: [], coverage: { kind: "no-block" } };
154
+ }
155
+ const sliced = await captureSlices(page, area);
156
+ return { screenshots: sliced.screenshots, coverage: { kind: "block", capturedPx: sliced.capturedPx, totalPx: area.toY - area.fromY } };
111
157
  }
112
158
  /**
113
159
  * Take the pictures of one settled page.
114
160
  *
115
161
  * A page at the single-shot bound or under gives one full-page shot. A taller page gives consecutive slices,
116
- * because the provider path compresses or rejects one tall picture; the module comment carries the account.
162
+ * because the model downscales or refuses one tall picture; the module comment carries the account.
117
163
  *
118
164
  * The compositor can refuse a bitmap of either shape while the bitmap of the window is fine. A degraded
119
165
  * picture beats a dead look, thus a refusal retries one time at the window. A second throw names a broken
@@ -122,8 +168,10 @@ async function captureTiles(page, totalPx) {
122
168
  async function captureShots(page) {
123
169
  const totalPx = await measureTotalHeight(page);
124
170
  try {
125
- if (totalPx > TILE_HEIGHT_PX)
126
- return await captureTiles(page, totalPx);
171
+ if (totalPx > TILE_HEIGHT_PX) {
172
+ const sliced = await captureSlices(page, { x: 0, width: VIEWPORT_WIDTH, fromY: 0, toY: totalPx });
173
+ return { screenshots: sliced.screenshots, coverage: { kind: "tiled", capturedPx: sliced.capturedPx, totalPx } };
174
+ }
127
175
  return { screenshots: [{ base64: toBase64(await page.screenshot({ encoding: "base64", fullPage: true })) }], coverage: { kind: "full" } };
128
176
  }
129
177
  catch (refused) {
@@ -146,7 +194,7 @@ async function captureShots(page) {
146
194
  * The pictures hold the document, and not the window alone. Thus a caller can judge a section that a reader
147
195
  * reaches by a scroll. A short page arrives as one full-page shot, and a tall page arrives as consecutive
148
196
  * slices in document order. A refused bitmap degrades to the window, and the coverage of the result names
149
- * what the pictures hold.
197
+ * what the pictures hold. A capture that names a block holds that block alone.
150
198
  *
151
199
  * The readiness wait is best-effort. A page that never signals still captures at the readiness budget, thus
152
200
  * a broken page gives a picture that shows what broke.
@@ -165,7 +213,7 @@ export function capturePage(chrome, url, options = {}) {
165
213
  });
166
214
  // The size is set before the navigation, because a layout resolves at load time. The connection gives
167
215
  // a small default window, and that window collapses each multi-column band of the design.
168
- await page.setViewport({ width: VIEWPORT_WIDTH, height: VIEWPORT_HEIGHT });
216
+ await page.setViewport({ width: VIEWPORT_WIDTH, height: VIEWPORT_HEIGHT, deviceScaleFactor: DEVICE_SCALE_FACTOR });
169
217
  // The preference is active before the navigation, because the page reveals its sections as it loads.
170
218
  // A preference that arrives after the load reaches a page that already runs its transitions.
171
219
  await page.emulateMediaFeatures([{ name: "prefers-reduced-motion", value: "reduce" }]);
@@ -185,7 +233,7 @@ export function capturePage(chrome, url, options = {}) {
185
233
  }
186
234
  // The pictures must show the whole document at the layout that a reader gets. Thus a defect below the
187
235
  // fold is visible, and a question about content that never appeared has an answer.
188
- const shots = await captureShots(page);
236
+ const shots = options.blockId === undefined ? await captureShots(page) : await captureBlock(page, options.blockId);
189
237
  return {
190
238
  screenshots: shots.screenshots,
191
239
  coverage: shots.coverage,
@@ -3,10 +3,15 @@ import type { ToolMask } from "./tool-mask.js";
3
3
  import type { LoopMessage } from "./types.js";
4
4
  /** The cap of requests of one compaction exchange: up to 3 rounds of memory edits, and then the summary. */
5
5
  export declare const COMPACTION_MAX_REQUESTS = 4;
6
- /** How the loop compacts its conversation when the view passes the budget. */
7
- export interface CompactionPolicy {
8
- /** The budget of the view, in estimated tokens. */
6
+ /** When an agent compacts its conversation, in the input tokens that the provider reported for the last request. */
7
+ export interface CompactionRules {
8
+ /** The budget before each request of a turn. */
9
9
  readonly budget: number;
10
+ /** The budget before the user message of a turn joins the conversation. Absent: no compaction before the user message. */
11
+ readonly turnStartBudget?: number;
12
+ }
13
+ /** How the loop compacts its conversation when the last request passed a budget. */
14
+ export interface CompactionPolicy extends CompactionRules {
10
15
  /** The provider of the conversation, with no text stream to the surface: the summary is no reply. */
11
16
  readonly provider: AgentChat;
12
17
  /** The text of the compaction request. */
@@ -67,6 +67,12 @@ export interface RunAgentOptions {
67
67
  * `recordsAfter` reads the database outside a step.
68
68
  */
69
69
  readonly compaction?: CompactionPolicy;
70
+ /**
71
+ * The messages that open the turn of a chat thread: the user message and its context records. The loop appends
72
+ * them after the turn-start compaction, thus the conversation holds each message in the order that it was sent.
73
+ * Absent: `initial` holds the whole conversation.
74
+ */
75
+ readonly turnInput?: readonly LoopMessage[];
70
76
  /**
71
77
  * Per-turn user-approval seam threaded into every tool's `ToolContext` as
72
78
  * `ctx.ask`. A conversation tool calls it to pause for an explicit user
@@ -184,6 +190,11 @@ export interface LoopSegment {
184
190
  * prefix (system prompt, tools, tool choice) as the one before it.
185
191
  */
186
192
  export interface OpenLoop {
193
+ /**
194
+ * Compact the conversation when the last request passed the turn-start budget, and then append the input of the
195
+ * turn. The result of the run when an abort ends the compaction, or `undefined` when the run continues.
196
+ */
197
+ startTurn(input: readonly LoopMessage[]): Promise<RunAgentResult | undefined>;
187
198
  /** Run one segment: the result of the run when a reply ends it, or `"capped"` when the segment used its cap. */
188
199
  runSegment(segment: LoopSegment): Promise<SegmentResult | "capped">;
189
200
  /** End a run after its last segment used its cap. */
@@ -8,8 +8,8 @@ import { ATTR_INFLEXA_TOOL_USE_ID, passThroughSpan, stableSpan } from "../lib/ot
8
8
  import { ResultError, unwrapOrThrow } from "../lib/result.js";
9
9
  import { describeZodIssueShapes } from "../lib/zod-issue-shape.js";
10
10
  import { hintForZodIssue, repairToolInput } from "../lib/zod-issues.js";
11
- import { dropMarkerMessage, markCompactionExchange, markInterruptedMessage, summaryMarkerMessage, syntheticUserMessage, } from "../memory/ai-sdk-message-storage.js";
12
- import { conversationView, keptTurnsForDrop, viewTokens } from "../memory/conversation-view.js";
11
+ import { contextRecordOf, markCompactionExchange, markInterruptedMessage, summaryMarkerMessage, syntheticUserMessage, withRequestInputTokens, } from "../memory/ai-sdk-message-storage.js";
12
+ import { conversationView, lastRequestInputTokens } from "../memory/conversation-view.js";
13
13
  import { answerUnansweredToolCalls } from "../memory/tool-call-integrity.js";
14
14
  import { classifyProviderError, extractStatus } from "../providers/errors.js";
15
15
  import { DEFAULT_PROMPT_CACHE, withPromptCacheBreakpoint, withSystemPromptBreakpoint } from "../providers/prompt-cache.js";
@@ -42,6 +42,11 @@ export const WRAP_UP_MAX_REQUESTS = 2;
42
42
  async function runAgentLoop(agent, initial, session, opts) {
43
43
  const formatStepName = opts.formatStepName ?? DEFAULT_STEP_NAME_FORMATTER;
44
44
  const loop = openLoop(agent, initial, session, opts, agent.id);
45
+ if (opts.turnInput !== undefined) {
46
+ const ended = await loop.startTurn(opts.turnInput);
47
+ if (ended !== undefined)
48
+ return ended;
49
+ }
45
50
  const task = await loop.runSegment({ mask: opts.toolMask, maxRequests: agent.maxIterations, stepNames: formatStepName, firstIndex: 0, checksBudget: true });
46
51
  if (task !== "capped")
47
52
  return task;
@@ -100,6 +105,8 @@ export function openLoop(agent, initial, session, opts, metricAgentId) {
100
105
  let continuesTruncation = false;
101
106
  let compactionStopped = false;
102
107
  let compactions = 0;
108
+ // The first request after a summary shows if the summary brought the conversation under the budget.
109
+ let summarizedLast = false;
103
110
  // Bound from the same `source` the emitted events carry: one derivation feeding
104
111
  // both sinks, so a record and an event cannot disagree about who produced them.
105
112
  // `callPath` rides as an array rather than a joined string — the queryable form;
@@ -224,7 +231,7 @@ export function openLoop(agent, initial, session, opts, metricAgentId) {
224
231
  if (!endsOnRequestRefusal) {
225
232
  const reply = await resultStep(callStep)(stepName, chat);
226
233
  accountForChatCall(reply, { ...accounting, stepName });
227
- return reply;
234
+ return withRequestAnchor(reply);
228
235
  }
229
236
  // The step gives the `Result` as its value, thus a refusal ends the segment and no step throws for it.
230
237
  const result = await callStep(stepName, async () => await chat());
@@ -233,7 +240,7 @@ export function openLoop(agent, initial, session, opts, metricAgentId) {
233
240
  return { refusal };
234
241
  const reply = unwrapOrThrow(result);
235
242
  accountForChatCall(reply, { ...accounting, stepName });
236
- return reply;
243
+ return withRequestAnchor(reply);
237
244
  };
238
245
  // The user said no. A subsequent model call would only let the agent argue
239
246
  // with the decision, or spend a call acknowledging it; the denial tool result
@@ -327,13 +334,13 @@ export function openLoop(agent, initial, session, opts, metricAgentId) {
327
334
  return { messages, finish: { reason: "error", cappedOut: false, truncationRecoveries, ...finishUsage() }, refusal };
328
335
  };
329
336
  /** The result of the run when an abort ends the exchange, or `undefined` when the run continues. */
330
- const compact = async (active, tokensBefore, index) => {
337
+ const compact = async (active, tokensBefore, index, trigger) => {
331
338
  const id = randomUUID();
332
339
  const stepNamespace = `compaction-${compactions++}`;
333
340
  const startedAt = performance.now();
334
- const emitPart = (data) => emit({ type: "data-compaction", source, data });
341
+ const emitPart = (data) => emit({ type: "data-compaction", source, data: { ...data, trigger } });
335
342
  await emitPart({ id, status: "running", tokensBefore });
336
- const { onRound: _onRound, compaction: _compaction, ...conversationOptions } = opts;
343
+ const { onRound: _onRound, compaction: _compaction, turnInput: _turnInput, ...conversationOptions } = opts;
337
344
  let settled = false;
338
345
  try {
339
346
  const exchange = await continueAgent(agent, viewOf(), {
@@ -357,36 +364,25 @@ export function openLoop(agent, initial, session, opts, metricAgentId) {
357
364
  return { messages, finish: { reason: "aborted", cappedOut: false, truncationRecoveries, ...finishUsage() } };
358
365
  }
359
366
  const summary = summaryOf(exchange);
360
- const keptTurns = summary === undefined ? keptTurnsForDrop(messages, viewOptions, active.budget) : 0;
361
- const markerOf = (tokensAfter, durationMs) => summary === undefined
362
- ? dropMarkerMessage({ kind: "drop", id, tokensBefore, tokensAfter, durationMs, keptTurns })
363
- : summaryMarkerMessage(summary, { kind: "summary", id, tokensBefore, tokensAfter, durationMs });
367
+ if (summary === undefined) {
368
+ settled = true;
369
+ const durationMs = Math.round(performance.now() - startedAt);
370
+ await emitPart({ id, status: "failed", tokensBefore, durationMs });
371
+ const refusal = exchange.refusal === undefined ? {} : { status: exchange.refusal.status, providerError: exchange.refusal.message };
372
+ log.error("compaction gave no summary, thus the run compacts no more", { compactionId: id, trigger, tokensBefore, durationMs, ...refusal });
373
+ compactionStopped = true;
374
+ return undefined;
375
+ }
376
+ const markerOf = (durationMs) => summaryMarkerMessage(summary, { kind: "summary", id, tokensBefore, durationMs, trigger });
364
377
  // The figures of a marker do not change the view, thus a draft marker gives the view of the real one.
365
- const view = conversationView([...messages, markerOf(0, 0)], viewOptions).messages;
366
- const records = await active.recordsAfter(view);
367
- const tokensAfter = viewTokens([...view, ...records]);
378
+ const records = await active.recordsAfter(conversationView([...messages, markerOf(0)], viewOptions).messages);
368
379
  const durationMs = Math.round(performance.now() - startedAt);
369
- messages.push(markerOf(tokensAfter, durationMs), ...records);
380
+ messages.push(markerOf(durationMs), ...records);
370
381
  await giveRound();
371
382
  settled = true;
372
- await emitPart({ id, status: summary === undefined ? "failed" : "done", tokensBefore, tokensAfter, durationMs });
373
- const fields = { compactionId: id, tokensBefore, tokensAfter, durationMs };
374
- if (summary === undefined) {
375
- const refusal = exchange.refusal === undefined ? {} : { status: exchange.refusal.status, providerError: exchange.refusal.message };
376
- log.warn("compaction gave no summary, thus the oldest turns left the view", { ...fields, keptTurns, ...refusal });
377
- }
378
- else {
379
- log.info("conversation compacted", fields);
380
- }
381
- if (summary !== undefined && tokensAfter > active.budget) {
382
- log.warn("the compacted view still exceeds the budget, thus the run compacts no more", {
383
- compactionId: id,
384
- tokensAfter,
385
- budget: active.budget,
386
- });
387
- }
388
- if (summary === undefined || tokensAfter > active.budget)
389
- compactionStopped = true;
383
+ await emitPart({ id, status: "done", tokensBefore, durationMs });
384
+ log.info("conversation compacted", { compactionId: id, trigger, tokensBefore, durationMs });
385
+ summarizedLast = true;
390
386
  return undefined;
391
387
  }
392
388
  finally {
@@ -394,11 +390,36 @@ export function openLoop(agent, initial, session, opts, metricAgentId) {
394
390
  await emitPart({ id, status: "failed", tokensBefore, durationMs: Math.round(performance.now() - startedAt) });
395
391
  }
396
392
  };
393
+ const startTurn = async (input) => {
394
+ const budget = policy?.turnStartBudget;
395
+ const tokens = lastRequestInputTokens(messages);
396
+ if (policy === undefined || budget === undefined || tokens === undefined || tokens <= budget) {
397
+ messages.push(...input);
398
+ return undefined;
399
+ }
400
+ const ended = await compact(policy, tokens, 0, "turn-start");
401
+ if (ended !== undefined)
402
+ return ended;
403
+ // The records after a summary give the current context, thus a record of the input would only repeat one.
404
+ messages.push(...(summarizedLast ? input.filter((message) => contextRecordOf(message) === undefined) : input));
405
+ return undefined;
406
+ };
397
407
  const compactOverBudget = async (index) => {
398
408
  if (policy === undefined || continuesTruncation || compactionStopped)
399
409
  return undefined;
400
- const tokensBefore = viewTokens(viewOf());
401
- return tokensBefore > policy.budget ? compact(policy, tokensBefore, index) : undefined;
410
+ const tokens = lastRequestInputTokens(messages);
411
+ if (tokens === undefined)
412
+ return undefined;
413
+ const afterSummary = summarizedLast;
414
+ summarizedLast = false;
415
+ if (tokens <= policy.budget)
416
+ return undefined;
417
+ if (afterSummary) {
418
+ log.warn("the first request after the summary still exceeds the budget, thus the run compacts no more", { tokens, budget: policy.budget });
419
+ compactionStopped = true;
420
+ return undefined;
421
+ }
422
+ return compact(policy, tokens, index, "mid-turn");
402
423
  };
403
424
  const runSegment = async (segment) => {
404
425
  const names = segment.stepNames;
@@ -558,6 +579,7 @@ export function openLoop(agent, initial, session, opts, metricAgentId) {
558
579
  }
559
580
  };
560
581
  return {
582
+ startTurn: (input) => giveRoundBeforeThrow(() => startTurn(input)),
561
583
  runSegment: (segment) => giveRoundBeforeThrow(() => runSegment(segment)),
562
584
  endCapped: () => giveRoundBeforeThrow(endCapped),
563
585
  };
@@ -577,6 +599,11 @@ function summaryOf(exchange) {
577
599
  const text = finalText(exchange.messages).trim();
578
600
  return text.length > 0 ? text : undefined;
579
601
  }
602
+ /** The reply whose message holds the input tokens of its request, which the budget checks read. */
603
+ function withRequestAnchor(reply) {
604
+ const tokens = reply.usage?.inputTokens;
605
+ return tokens === undefined ? reply : { ...reply, message: withRequestInputTokens(reply.message, tokens) };
606
+ }
580
607
  /**
581
608
  * Called at the fold point, before any run-ending branch, so an aborted or
582
609
  * dead run is still accounted. Token counters grow inside the call's step
@@ -11,6 +11,7 @@ import type { ModelMessage } from "ai";
11
11
  import type { ToolCallDetail, ToolOutcome } from "../contracts/chat-events.js";
12
12
  import type { ChatStreamEvent } from "../providers/types.js";
13
13
  import type { Tool } from "../tools/define-tool.js";
14
+ import type { CompactionRules } from "./compaction.js";
14
15
  /**
15
16
  * A loop message. The harness's working message array is AI SDK-shaped
16
17
  * `ModelMessage`.
@@ -28,6 +29,8 @@ export interface AgentDefinition {
28
29
  readonly tools: readonly Tool[];
29
30
  /** Runaway guard: at the cap the loop runs up to two masked wrap-up requests, until it gets a text answer. */
30
31
  readonly maxIterations: number;
32
+ /** The compaction rules of an agent that a chat thread runs. Absent: the conversation never compacts. */
33
+ readonly compaction?: CompactionRules;
31
34
  }
32
35
  /**
33
36
  * The durability seam. `passthroughStep` (`(_, fn) => fn()`) in the chat
@@ -1,4 +1,4 @@
1
- import { type ModelMessage, type UserContent } from "ai";
1
+ import { type AssistantModelMessage, type ModelMessage, type UserContent } from "ai";
2
2
  import { z } from "zod";
3
3
  export declare const SUPPORTED_AI_SDK_MAJOR = 7;
4
4
  export declare const StoredMessageEnvelopeSchema: z.ZodObject<{
@@ -110,21 +110,36 @@ export declare function isSyntheticRecordMessage(message: ModelMessage): boolean
110
110
  export declare const COMPACTION_EXCHANGE_KEY = "compactionExchange";
111
111
  /** The {@link HARNESS_PROVIDER_NAMESPACE} key that holds the figures of a compaction marker, which the view rule reads. */
112
112
  export declare const COMPACTION_MARKER_KEY = "compactionMarker";
113
+ declare const COMPACTION_TRIGGERS: readonly ["turn-start", "mid-turn"];
114
+ /** Where a compaction started: before the user message of a turn, or before a later request. */
115
+ export type CompactionTrigger = (typeof COMPACTION_TRIGGERS)[number];
113
116
  declare const CompactionMarkerSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
114
117
  id: z.ZodString;
115
118
  tokensBefore: z.ZodNumber;
116
- tokensAfter: z.ZodNumber;
117
119
  durationMs: z.ZodNumber;
120
+ trigger: z.ZodOptional<z.ZodEnum<{
121
+ "turn-start": "turn-start";
122
+ "mid-turn": "mid-turn";
123
+ }>>;
118
124
  kind: z.ZodLiteral<"summary">;
119
125
  }, z.core.$strip>, z.ZodObject<{
120
126
  keptTurns: z.ZodNumber;
121
127
  id: z.ZodString;
122
128
  tokensBefore: z.ZodNumber;
123
- tokensAfter: z.ZodNumber;
124
129
  durationMs: z.ZodNumber;
130
+ trigger: z.ZodOptional<z.ZodEnum<{
131
+ "turn-start": "turn-start";
132
+ "mid-turn": "mid-turn";
133
+ }>>;
125
134
  kind: z.ZodLiteral<"drop">;
126
135
  }, z.core.$strip>], "kind">;
127
136
  export type CompactionMarker = z.infer<typeof CompactionMarkerSchema>;
137
+ /** The {@link HARNESS_PROVIDER_NAMESPACE} key that holds the input tokens that the provider reported for the request of an assistant message. */
138
+ export declare const REQUEST_INPUT_TOKENS_KEY = "requestInputTokens";
139
+ /** A copy of `message` whose harness namespace holds the input tokens of its request. The mark keeps each other namespace. */
140
+ export declare function withRequestInputTokens(message: AssistantModelMessage, tokens: number): AssistantModelMessage;
141
+ /** The input tokens of the request of an assistant message, or `undefined` when the provider reported none. */
142
+ export declare function requestInputTokensOf(message: ModelMessage): number | undefined;
128
143
  /** A copy of `message` whose harness namespace, which no provider reads, holds the compaction id. */
129
144
  export declare function markCompactionExchange(message: ModelMessage, id: string): ModelMessage;
130
145
  export declare function compactionExchangeOf(message: ModelMessage): string | undefined;
@@ -162,11 +162,33 @@ export function isSyntheticRecordMessage(message) {
162
162
  export const COMPACTION_EXCHANGE_KEY = "compactionExchange";
163
163
  /** The {@link HARNESS_PROVIDER_NAMESPACE} key that holds the figures of a compaction marker, which the view rule reads. */
164
164
  export const COMPACTION_MARKER_KEY = "compactionMarker";
165
- const compactionFigures = { id: z.string(), tokensBefore: z.number(), tokensAfter: z.number(), durationMs: z.number() };
165
+ const COMPACTION_TRIGGERS = ["turn-start", "mid-turn"];
166
+ // A row from before the trigger has none.
167
+ const compactionFigures = { id: z.string(), tokensBefore: z.number(), durationMs: z.number(), trigger: z.enum(COMPACTION_TRIGGERS).optional() };
166
168
  const CompactionMarkerSchema = z.discriminatedUnion("kind", [
167
169
  z.object({ kind: z.literal("summary"), ...compactionFigures }),
168
170
  z.object({ kind: z.literal("drop"), ...compactionFigures, keptTurns: z.number() }),
169
171
  ]);
172
+ /** The {@link HARNESS_PROVIDER_NAMESPACE} key that holds the input tokens that the provider reported for the request of an assistant message. */
173
+ export const REQUEST_INPUT_TOKENS_KEY = "requestInputTokens";
174
+ /** A copy of `message` whose harness namespace holds the input tokens of its request. The mark keeps each other namespace. */
175
+ export function withRequestInputTokens(message, tokens) {
176
+ const existingOptions = message.providerOptions ?? {};
177
+ return {
178
+ ...message,
179
+ providerOptions: {
180
+ ...existingOptions,
181
+ [HARNESS_PROVIDER_NAMESPACE]: { ...existingOptions[HARNESS_PROVIDER_NAMESPACE], [REQUEST_INPUT_TOKENS_KEY]: tokens },
182
+ },
183
+ };
184
+ }
185
+ /** The input tokens of the request of an assistant message, or `undefined` when the provider reported none. */
186
+ export function requestInputTokensOf(message) {
187
+ if (message.role !== "assistant")
188
+ return undefined;
189
+ const tokens = message.providerOptions?.[HARNESS_PROVIDER_NAMESPACE]?.[REQUEST_INPUT_TOKENS_KEY];
190
+ return typeof tokens === "number" && Number.isFinite(tokens) ? tokens : undefined;
191
+ }
170
192
  /** A copy of `message` whose harness namespace, which no provider reads, holds the compaction id. */
171
193
  export function markCompactionExchange(message, id) {
172
194
  const existingOptions = message.providerOptions ?? {};
@@ -15,9 +15,15 @@ export interface ConversationDisplayRecorder {
15
15
  }
16
16
  export interface ConversationDisplayRecorderOptions {
17
17
  readonly userText: string;
18
- readonly topLevelCallPath: readonly string[];
18
+ /** The root agent of the turn. Its call path is `[agentId]`, and an event with a longer path is from a sub-agent. */
19
+ readonly agentId: string;
19
20
  readonly sink: EmitFn;
20
21
  readonly userMessageId?: string;
21
22
  readonly assistantMessageId?: string;
22
23
  }
24
+ /**
25
+ * Record the display of a turn through the same translation path as a live surface: each event becomes a
26
+ * frame (`toChatFrame`), and the frames build the messages of the round (`applyChatFrame`). Only a part that
27
+ * the conversation keeps enters the record.
28
+ */
23
29
  export declare function createConversationDisplayRecorder(options: ConversationDisplayRecorderOptions): ConversationDisplayRecorder;