@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.
- package/dist/agents/conversation-agent.js +3 -0
- package/dist/agents/report-session-agent.d.ts +2 -2
- package/dist/agents/report-session-agent.js +4 -0
- package/dist/app/chat-turn.d.ts +0 -4
- package/dist/app/chat-turn.js +39 -25
- package/dist/app/message-assembly.d.ts +2 -2
- package/dist/app/message-assembly.js +1 -1
- package/dist/contracts/chat-events.d.ts +12 -5
- package/dist/contracts/chat-events.js +3 -3
- package/dist/contracts/chat-frame.d.ts +40 -0
- package/dist/contracts/chat-frame.js +166 -0
- package/dist/contracts/chat-parts.d.ts +15 -3
- package/dist/contracts/chat-parts.js +2 -2
- package/dist/contracts/index.d.ts +6 -4
- package/dist/contracts/index.js +1 -0
- package/dist/contracts/message.d.ts +7 -5
- package/dist/contracts/message.js +1 -1
- package/dist/contracts/part-registry.d.ts +6 -6
- package/dist/contracts/report-blocks.d.ts +10 -10
- package/dist/contracts/report-reference.d.ts +4 -4
- package/dist/contracts/schemas/chat-events.d.ts +2 -2
- package/dist/contracts/schemas/chat-events.js +2 -2
- package/dist/contracts/schemas/chat-parts.d.ts +34 -2
- package/dist/contracts/schemas/chat-parts.js +9 -3
- package/dist/contracts/schemas/index.d.ts +2 -2
- package/dist/contracts/schemas/index.js +2 -2
- package/dist/execution/run-event-parts.d.ts +4 -4
- package/dist/execution/run-event-parts.js +0 -0
- package/dist/execution/run-event-stream.d.ts +2 -2
- package/dist/index.d.ts +9 -6
- package/dist/index.js +3 -2
- package/dist/lib/page-capture.d.ts +27 -9
- package/dist/lib/page-capture.js +81 -33
- package/dist/loop/compaction.d.ts +8 -3
- package/dist/loop/run-agent.d.ts +11 -0
- package/dist/loop/run-agent.js +62 -35
- package/dist/loop/types.d.ts +3 -0
- package/dist/memory/ai-sdk-message-storage.d.ts +18 -3
- package/dist/memory/ai-sdk-message-storage.js +23 -1
- package/dist/memory/conversation-display-recorder.d.ts +7 -1
- package/dist/memory/conversation-display-recorder.js +59 -119
- package/dist/memory/conversation-display-replay.d.ts +3 -3
- package/dist/memory/conversation-display-replay.js +7 -21
- package/dist/memory/conversation-display-storage.d.ts +10 -10
- package/dist/memory/conversation-display-storage.js +9 -8
- package/dist/memory/conversation-view.d.ts +5 -4
- package/dist/memory/conversation-view.js +14 -18
- package/dist/memory/count-tokens.js +1 -0
- package/dist/memory/message-backfill.d.ts +8 -2
- package/dist/memory/thread-store.d.ts +9 -2
- package/dist/memory/thread-store.js +8 -2
- package/dist/prompts/compaction.d.ts +2 -2
- package/dist/prompts/compaction.js +2 -2
- package/dist/prompts/conversation.d.ts +1 -1
- package/dist/prompts/conversation.js +12 -0
- package/dist/prompts/report-session.d.ts +1 -1
- package/dist/prompts/report-session.js +40 -28
- package/dist/report-model/authoring-grammar.d.ts +4 -4
- package/dist/report-render/views/block-mark.d.ts +8 -0
- package/dist/report-render/views/block-mark.js +10 -0
- package/dist/report-render/views/chart-view.js +2 -1
- package/dist/report-render/views/prose.js +10 -6
- package/dist/report-render/views/values.js +5 -4
- package/dist/runtime/boot.d.ts +2 -2
- package/dist/runtime/boot.js +3 -3
- package/dist/state/init.d.ts +7 -6
- package/dist/state/init.js +51 -672
- package/dist/state/migrations/20260927120000_baseline.d.ts +11 -0
- package/dist/state/migrations/20260927120000_baseline.js +675 -0
- package/dist/state/migrations/20260927180000_thread_title_set_by_user.d.ts +6 -0
- package/dist/state/migrations/20260927180000_thread_title_set_by_user.js +10 -0
- package/dist/state/migrations/index.d.ts +10 -0
- package/dist/state/migrations/index.js +15 -0
- package/dist/tools/display/show-user.js +6 -4
- package/dist/tools/report-authoring/authoring-tools.d.ts +16 -5
- package/dist/tools/report-authoring/authoring-tools.js +52 -24
- package/dist/tools/report-session/examine-page.d.ts +29 -14
- package/dist/tools/report-session/examine-page.js +40 -16
- package/dist/tools/report-session/list-artifacts.d.ts +23 -10
- package/dist/tools/report-session/list-artifacts.js +51 -31
- package/dist/tools/report-session/page-faults.d.ts +29 -0
- package/dist/tools/report-session/page-faults.js +48 -0
- package/dist/tools/report-session/preview-report.d.ts +9 -2
- package/dist/tools/report-session/preview-report.js +13 -3
- package/dist/tools/report-session/record-version.d.ts +5 -1
- package/dist/tools/report-session/record-version.js +4 -3
- package/dist/tools/workspace/show-file.js +4 -3
- package/package.json +2 -1
package/dist/lib/page-capture.js
CHANGED
|
@@ -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
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
|
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
|
|
48
|
+
const DEVICE_SCALE_FACTOR = 0.5;
|
|
48
49
|
/**
|
|
49
|
-
* The
|
|
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
|
|
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
|
|
98
|
-
*
|
|
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
|
|
101
|
-
const
|
|
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,
|
|
106
|
-
const clip = { x:
|
|
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
|
-
|
|
110
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
/**
|
|
7
|
-
export interface
|
|
8
|
-
/** The budget
|
|
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. */
|
package/dist/loop/run-agent.d.ts
CHANGED
|
@@ -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. */
|
package/dist/loop/run-agent.js
CHANGED
|
@@ -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 {
|
|
12
|
-
import { conversationView,
|
|
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
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
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
|
|
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(
|
|
380
|
+
messages.push(markerOf(durationMs), ...records);
|
|
370
381
|
await giveRound();
|
|
371
382
|
settled = true;
|
|
372
|
-
await emitPart({ id, status:
|
|
373
|
-
|
|
374
|
-
|
|
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
|
|
401
|
-
|
|
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
|
package/dist/loop/types.d.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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;
|