@inflexa-ai/harness 0.18.0 → 0.19.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/dist/agents/report-session-agent.d.ts +72 -0
  2. package/dist/agents/report-session-agent.js +87 -0
  3. package/dist/app/report-session-runtime.d.ts +72 -0
  4. package/dist/app/report-session-runtime.js +183 -0
  5. package/dist/app/spawn-report-session.d.ts +32 -5
  6. package/dist/app/spawn-report-session.js +19 -0
  7. package/dist/config/environment-stores.d.ts +12 -7
  8. package/dist/config/resource-limits.d.ts +6 -10
  9. package/dist/config/resource-limits.js +6 -44
  10. package/dist/contracts/report-reference.d.ts +4 -0
  11. package/dist/contracts/report-reference.js +3 -0
  12. package/dist/index.d.ts +2 -1
  13. package/dist/lib/chrome.d.ts +14 -0
  14. package/dist/lib/chrome.js +7 -0
  15. package/dist/lib/page-capture.d.ts +47 -0
  16. package/dist/lib/page-capture.js +83 -0
  17. package/dist/loop/run-agent.js +104 -15
  18. package/dist/memory/ai-sdk-message-storage.d.ts +9 -4
  19. package/dist/memory/ai-sdk-message-storage.js +9 -4
  20. package/dist/memory/count-tokens.js +34 -5
  21. package/dist/memory/thread-store.d.ts +4 -4
  22. package/dist/memory/thread-store.js +18 -9
  23. package/dist/prompts/report-session.d.ts +1 -0
  24. package/dist/prompts/report-session.js +110 -0
  25. package/dist/providers/ai-sdk.d.ts +158 -3
  26. package/dist/providers/ai-sdk.js +234 -23
  27. package/dist/providers/errors.d.ts +14 -0
  28. package/dist/providers/errors.js +103 -18
  29. package/dist/providers/types.d.ts +33 -0
  30. package/dist/providers/types.js +10 -1
  31. package/dist/report-model/assert-rules.d.ts +44 -0
  32. package/dist/report-model/assert-rules.js +97 -0
  33. package/dist/report-model/draft-hash.d.ts +14 -0
  34. package/dist/report-model/draft-hash.js +36 -0
  35. package/dist/report-model/fixture-resolver.js +2 -71
  36. package/dist/report-model/{mint-snapshot.d.ts → pin-snapshot.d.ts} +7 -7
  37. package/dist/report-model/{mint-snapshot.js → pin-snapshot.js} +7 -7
  38. package/dist/report-model/production-resolver.d.ts +124 -0
  39. package/dist/report-model/production-resolver.js +801 -0
  40. package/dist/report-model/reference-resolver.d.ts +7 -1
  41. package/dist/report-model/validate.d.ts +34 -2
  42. package/dist/report-model/validate.js +37 -17
  43. package/dist/report-render/page.d.ts +23 -0
  44. package/dist/report-render/page.js +36 -3
  45. package/dist/report-render/value-bridge.d.ts +62 -0
  46. package/dist/report-render/value-bridge.js +82 -0
  47. package/dist/runtime/assemble.d.ts +39 -0
  48. package/dist/runtime/assemble.js +67 -6
  49. package/dist/sandbox/mount-plan.d.ts +7 -0
  50. package/dist/sandbox/mount-plan.js +7 -2
  51. package/dist/state/init.js +36 -0
  52. package/dist/state/purge-analysis.js +1 -0
  53. package/dist/state/report-session-state.d.ts +139 -0
  54. package/dist/state/report-session-state.js +177 -0
  55. package/dist/state/report-versions.d.ts +3 -6
  56. package/dist/state/report-versions.js +11 -38
  57. package/dist/state/snapshot-parse.d.ts +34 -0
  58. package/dist/state/snapshot-parse.js +82 -0
  59. package/dist/tasks/extract-values-script.d.ts +64 -0
  60. package/dist/tasks/extract-values-script.js +180 -0
  61. package/dist/tasks/extract-values.d.ts +124 -0
  62. package/dist/tasks/extract-values.js +193 -0
  63. package/dist/tools/ad-hoc-router.d.ts +7 -2
  64. package/dist/tools/ad-hoc-router.js +2 -1
  65. package/dist/tools/define-tool.d.ts +29 -0
  66. package/dist/tools/define-tool.js +26 -0
  67. package/dist/tools/report/preview-snapshot.d.ts +1 -1
  68. package/dist/tools/report/preview-snapshot.js +11 -50
  69. package/dist/tools/report-authoring/authoring-tools.d.ts +181 -24
  70. package/dist/tools/report-authoring/authoring-tools.js +152 -44
  71. package/dist/tools/report-session/examine-page.d.ts +90 -0
  72. package/dist/tools/report-session/examine-page.js +136 -0
  73. package/dist/tools/report-session/index.d.ts +10 -0
  74. package/dist/tools/report-session/index.js +10 -0
  75. package/dist/tools/report-session/preview-report.d.ts +105 -0
  76. package/dist/tools/report-session/preview-report.js +269 -0
  77. package/dist/tools/report-session/record-version.d.ts +102 -0
  78. package/dist/tools/report-session/record-version.js +139 -0
  79. package/dist/tools/research/generate-plan.js +4 -2
  80. package/dist/tools/sandbox/list-available-packages.js +2 -1
  81. package/dist/tools/sandbox/list-available-refs.d.ts +5 -1
  82. package/dist/tools/sandbox/list-available-refs.js +13 -6
  83. package/package.json +10 -8
@@ -313,6 +313,8 @@ export declare const ScalarReferenceSchema: z.ZodDiscriminatedUnion<[z.ZodObject
313
313
  * the path. `unreadable-artifact` means that the entry exists, and that the resolver cannot read the
314
314
  * artifact as a table. Two conditions give the second reason. The file type refuses a read of a cell, or
315
315
  * the bytes are unrecoverable.
316
+ *
317
+ * `extraction-unavailable` means that the value tier must use an extraction arm that is not wired.
316
318
  */
317
319
  export declare const UnresolvedReasonSchema: z.ZodEnum<{
318
320
  "artifact-missing": "artifact-missing";
@@ -321,6 +323,7 @@ export declare const UnresolvedReasonSchema: z.ZodEnum<{
321
323
  "ambiguous-match": "ambiguous-match";
322
324
  "assertion-failed": "assertion-failed";
323
325
  "unreadable-artifact": "unreadable-artifact";
326
+ "extraction-unavailable": "extraction-unavailable";
324
327
  }>;
325
328
  /** A reference that resolution could not bind, with the reason and an optional detail. */
326
329
  export declare const UnresolvedReferenceSchema: z.ZodObject<{
@@ -419,6 +422,7 @@ export declare const UnresolvedReferenceSchema: z.ZodObject<{
419
422
  "ambiguous-match": "ambiguous-match";
420
423
  "assertion-failed": "assertion-failed";
421
424
  "unreadable-artifact": "unreadable-artifact";
425
+ "extraction-unavailable": "extraction-unavailable";
422
426
  }>;
423
427
  detail: z.ZodOptional<z.ZodString>;
424
428
  }, z.core.$strict>;
@@ -175,6 +175,8 @@ export const ScalarReferenceSchema = z.discriminatedUnion("kind", [ArtifactValue
175
175
  * the path. `unreadable-artifact` means that the entry exists, and that the resolver cannot read the
176
176
  * artifact as a table. Two conditions give the second reason. The file type refuses a read of a cell, or
177
177
  * the bytes are unrecoverable.
178
+ *
179
+ * `extraction-unavailable` means that the value tier must use an extraction arm that is not wired.
178
180
  */
179
181
  export const UnresolvedReasonSchema = z.enum([
180
182
  "artifact-missing",
@@ -183,6 +185,7 @@ export const UnresolvedReasonSchema = z.enum([
183
185
  "ambiguous-match",
184
186
  "assertion-failed",
185
187
  "unreadable-artifact",
188
+ "extraction-unavailable",
186
189
  ]);
187
190
  /** A reference that resolution could not bind, with the reason and an optional detail. */
188
191
  export const UnresolvedReferenceSchema = z.strictObject({
package/dist/index.d.ts CHANGED
@@ -5,7 +5,8 @@
5
5
  * the agent with its own seam realizations.
6
6
  */
7
7
  export { assembleCoreRuntime } from "./runtime/assemble.js";
8
- export type { CoreRuntime, CoreRuntimeDeps, CoreWorkflowDeps, RegisteredWorkflows, ConversationAssemblyDeps, SandboxStepCallable } from "./runtime/assemble.js";
8
+ export type { CoreRuntime, CoreRuntimeDeps, CoreWorkflowDeps, RegisteredWorkflows, ConversationAssemblyDeps, SandboxStepCallable, MakeReportReferenceResolver, ReportResolverScope, } from "./runtime/assemble.js";
9
+ export type { ReferenceResolver, ReportSnapshot, ResolvedValue } from "./report-model/reference-resolver.js";
9
10
  export type { ThreadAgentResolver, UnregisteredThreadType } from "./runtime/assemble.js";
10
11
  export { bootHarness } from "./runtime/boot.js";
11
12
  export type { BootHarnessDeps, BootedHarness } from "./runtime/boot.js";
@@ -11,8 +11,22 @@ import type { Logger } from "./logger.js";
11
11
  export interface ChromeConfig {
12
12
  /** Operational logging seam; omitted falls back to no-op. */
13
13
  readonly logger?: Logger;
14
+ /**
15
+ * The endpoint of the Chrome sidecar. The connection is out of process, thus the sidecar has its own
16
+ * filesystem and its own network position. A `file://` URL resolves against the filesystem of the
17
+ * sidecar and never against the filesystem of the harness host. A caller that navigates to a workspace
18
+ * file must run a sidecar that mounts the same workspace tree at the same path.
19
+ *
20
+ * An absent value means that the composition gives no browser at all. A caller that needs a look must
21
+ * report that condition, not attempt a connection.
22
+ */
14
23
  readonly browserUrl?: string;
15
24
  readonly maxPages?: number;
16
25
  }
26
+ /**
27
+ * Whether the config names a browser to connect to. A composition with no endpoint has no eyes, and a
28
+ * caller reads that up front instead of failing once for each attempted connection.
29
+ */
30
+ export declare function hasBrowserUrl(cfg: ChromeConfig): boolean;
17
31
  export declare function getBrowser(browserUrl?: string, injected?: Logger): Promise<Browser>;
18
32
  export declare function withPage<T>(cfg: ChromeConfig, fn: (page: Page, context: BrowserContext) => Promise<T>): Promise<T>;
@@ -8,6 +8,13 @@
8
8
  */
9
9
  import puppeteer from "puppeteer-core";
10
10
  import { createNoopLogger } from "./console-logger.js";
11
+ /**
12
+ * Whether the config names a browser to connect to. A composition with no endpoint has no eyes, and a
13
+ * caller reads that up front instead of failing once for each attempted connection.
14
+ */
15
+ export function hasBrowserUrl(cfg) {
16
+ return cfg.browserUrl !== undefined && cfg.browserUrl.trim().length > 0;
17
+ }
11
18
  let browser;
12
19
  let connecting;
13
20
  let semaphore;
@@ -0,0 +1,47 @@
1
+ /**
2
+ * The one page capture over the Chrome sidecar.
3
+ *
4
+ * A capture navigates to a page URL, collects the console errors and the failed requests, waits for the
5
+ * readiness signal of the page, and gives back a base64 screenshot. Two tools capture a report page: the
6
+ * preview snapshot of the old report path and the eyes tool of a report session. Both read this module.
7
+ *
8
+ * The readiness contract is the reason for one shared body. The renderer emits the event name and the
9
+ * sentinel name, and `report-render/page.ts` owns both constants together with the two budgets. This module
10
+ * imports them, thus a rename or a retime reaches the waiter at compile time. A second copy of the wait
11
+ * script would instead degrade every capture to the readiness timeout, and no type would break.
12
+ *
13
+ * The capture speaks the throw protocol, because the chrome connection does. A caller that needs a typed
14
+ * outcome guards the call.
15
+ */
16
+ import { type ChromeConfig } from "./chrome.js";
17
+ /** One request that the page could not load, with the reason that the browser gave. */
18
+ export interface FailedRequest {
19
+ url: string;
20
+ reason: string;
21
+ }
22
+ /** The picture and the faults of one page capture. */
23
+ export interface PageCapture {
24
+ screenshotBase64: string;
25
+ consoleErrors: string[];
26
+ failedRequests: FailedRequest[];
27
+ }
28
+ /**
29
+ * The capture seam. It navigates to a page URL, and it gives back the screenshot and the faults. The seam
30
+ * speaks the throw protocol, because the chrome connection does. A test injects a seam that reads no
31
+ * browser, thus a tool orchestration runs with no chrome sidecar.
32
+ */
33
+ export type CapturePage = (url: string) => Promise<PageCapture>;
34
+ /** The extra settle steps that one call site needs. A call site that needs neither passes nothing. */
35
+ export interface CaptureOptions {
36
+ /** A CSS selector to wait for after the readiness signal. The wait is best-effort. */
37
+ readonly waitForSelector?: string;
38
+ /** An extra settle time in milliseconds after the readiness signal, for a late paint. */
39
+ readonly waitMs?: number;
40
+ }
41
+ /**
42
+ * Capture one page: the screenshot, the console errors, and the failed requests.
43
+ *
44
+ * The readiness wait is best-effort. A page that never signals still captures at the readiness budget, thus
45
+ * a broken page gives a picture that shows what broke.
46
+ */
47
+ export declare function capturePage(chrome: ChromeConfig, url: string, options?: CaptureOptions): Promise<PageCapture>;
@@ -0,0 +1,83 @@
1
+ /**
2
+ * The one page capture over the Chrome sidecar.
3
+ *
4
+ * A capture navigates to a page URL, collects the console errors and the failed requests, waits for the
5
+ * readiness signal of the page, and gives back a base64 screenshot. Two tools capture a report page: the
6
+ * preview snapshot of the old report path and the eyes tool of a report session. Both read this module.
7
+ *
8
+ * The readiness contract is the reason for one shared body. The renderer emits the event name and the
9
+ * sentinel name, and `report-render/page.ts` owns both constants together with the two budgets. This module
10
+ * imports them, thus a rename or a retime reaches the waiter at compile time. A second copy of the wait
11
+ * script would instead degrade every capture to the readiness timeout, and no type would break.
12
+ *
13
+ * The capture speaks the throw protocol, because the chrome connection does. A caller that needs a typed
14
+ * outcome guards the call.
15
+ */
16
+ import { withPage } from "./chrome.js";
17
+ import { PAGE_NAV_TIMEOUT_MS, PAGE_READY_TIMEOUT_MS, THEME_READY_EVENT, THEME_READY_SENTINEL } from "../report-render/page.js";
18
+ /** The budget of the optional selector wait. The wait is best-effort, thus a miss captures the page anyway. */
19
+ const SELECTOR_TIMEOUT_MS = 5_000;
20
+ /**
21
+ * The body of the readiness wait, in the browser context. The sentinel arm resolves a page that dispatched
22
+ * the event before this wait registered, thus a plain listener never blocks forever. The timer arm bounds a
23
+ * page that never signals.
24
+ *
25
+ * The browser runs this body as source text. Thus the body reads no module binding, and each value that it
26
+ * needs arrives as a parameter. The page evaluation sends a parameter as data, thus no value becomes code.
27
+ */
28
+ function waitForThemeReady(sentinel, event, timeout) {
29
+ return new Promise((resolve) => {
30
+ // The sentinel name arrives as a value, thus no declared property of the global object describes it.
31
+ if (window[sentinel]) {
32
+ resolve();
33
+ return;
34
+ }
35
+ const timer = setTimeout(() => {
36
+ resolve();
37
+ }, timeout);
38
+ document.addEventListener(event, () => {
39
+ clearTimeout(timer);
40
+ resolve();
41
+ }, { once: true });
42
+ });
43
+ }
44
+ /**
45
+ * Capture one page: the screenshot, the console errors, and the failed requests.
46
+ *
47
+ * The readiness wait is best-effort. A page that never signals still captures at the readiness budget, thus
48
+ * a broken page gives a picture that shows what broke.
49
+ */
50
+ export function capturePage(chrome, url, options = {}) {
51
+ return withPage(chrome, async (page) => {
52
+ const consoleErrors = [];
53
+ const failedRequests = [];
54
+ page.on("console", (msg) => {
55
+ if (msg.type() === "error")
56
+ consoleErrors.push(msg.text());
57
+ });
58
+ page.on("pageerror", (err) => consoleErrors.push(`pageerror: ${err.message}`));
59
+ page.on("requestfailed", (req) => {
60
+ failedRequests.push({ url: req.url(), reason: req.failure()?.errorText ?? "unknown" });
61
+ });
62
+ await page.goto(url, { waitUntil: "networkidle2", timeout: PAGE_NAV_TIMEOUT_MS });
63
+ // The two names and the budget ride as arguments, because the browser context has no module scope.
64
+ await page.evaluate(waitForThemeReady, THEME_READY_SENTINEL, THEME_READY_EVENT, PAGE_READY_TIMEOUT_MS).catch(() => {
65
+ /* the picture captures as it stands */
66
+ });
67
+ if (options.waitForSelector !== undefined) {
68
+ await page.waitForSelector(options.waitForSelector, { timeout: SELECTOR_TIMEOUT_MS }).catch(() => {
69
+ /* the picture captures as it stands */
70
+ });
71
+ }
72
+ if (options.waitMs !== undefined && options.waitMs > 0) {
73
+ const settle = options.waitMs;
74
+ await new Promise((resolve) => setTimeout(resolve, settle));
75
+ }
76
+ const screenshot = await page.screenshot({ encoding: "base64", fullPage: false });
77
+ return {
78
+ screenshotBase64: typeof screenshot === "string" ? screenshot : Buffer.from(screenshot).toString("base64"),
79
+ consoleErrors,
80
+ failedRequests,
81
+ };
82
+ });
83
+ }
@@ -1,5 +1,5 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { jsonSchema, tool as aiTool } from "ai";
2
+ import { jsonSchema, tool as aiTool, } from "ai";
3
3
  import { createNoopUsageRecorder } from "../billing/noop-usage-recorder.js";
4
4
  import { stripNulCharacters } from "../input-sanitization.js";
5
5
  import { createNoopLogger } from "../lib/console-logger.js";
@@ -9,7 +9,7 @@ import { classifyProviderError } from "../providers/errors.js";
9
9
  import { DEFAULT_PROMPT_CACHE, promptCacheProviderOptions } from "../providers/prompt-cache.js";
10
10
  import { resultStep } from "./run-step.js";
11
11
  import { AskRejectedError, UnavailableAsk } from "../tools/approval/contract.js";
12
- import { isToolError } from "../tools/define-tool.js";
12
+ import { isToolError, readToolResultImage } from "../tools/define-tool.js";
13
13
  import { addChatUsage, hasReportedUsage, recordAgentRun } from "./metrics.js";
14
14
  import { computeDetail } from "./tool-detail.js";
15
15
  import { toolOutcomeForOutputType } from "./tool-outcome.js";
@@ -40,6 +40,10 @@ export async function runAgent(agent, initial, session, opts) {
40
40
  // `callPath` rides as an array rather than a joined string — the queryable form;
41
41
  // rendering `parent > child` is the sink's choice, not a published package's.
42
42
  const log = (opts.logger ?? createNoopLogger()).named("loop").with({ agentId: source.agentId, callPath: source.callPath });
43
+ // The picture of a tool result rides where the wire renders it. Absent flags
44
+ // mean "cannot carry", thus the loop degrades to text and records the drop.
45
+ // The collector belongs to this run, thus two loops never share one.
46
+ const encoding = { placement: imagePlacementFor(provider.capabilities), deferredImages: [], log };
43
47
  const toolsById = new Map(agent.tools.map((t) => [t.id, t]));
44
48
  const toolDefs = Object.fromEntries(agent.tools.map((t) => [
45
49
  t.id,
@@ -238,7 +242,7 @@ export async function runAgent(agent, initial, session, opts) {
238
242
  for (const [idx, tu] of earlier.entries()) {
239
243
  await emit({ type: "tool-started", source, toolUseId: tu.toolCallId, name: tu.toolName, input: tu.input, ...detailField(earlierDetails[idx]) });
240
244
  }
241
- const { results, durations } = await dispatchTools(earlier, toolsById, toolCtx, isFatalLoopError, runStep, formatStepName.tool);
245
+ const { results, durations } = await dispatchTools(earlier, toolsById, toolCtx, isFatalLoopError, runStep, formatStepName.tool, encoding);
242
246
  const errored = await settleRound(earlier, results, earlierDetails, durations);
243
247
  results.push(errorResult(trailing, TRUNCATED_TOOL_USE_ERROR));
244
248
  // The trailing call was never dispatched, but it reaches the model as an error
@@ -250,6 +254,7 @@ export async function runAgent(agent, initial, session, opts) {
250
254
  errored.push(trailing.toolName);
251
255
  log.debug("tool results returned errors", { iteration: i, tools: errored });
252
256
  messages.push({ role: "tool", content: results });
257
+ appendDeferredImages(messages, results, encoding.deferredImages);
253
258
  if (hasDenial(results))
254
259
  return stopOnDenial(i);
255
260
  if (opts.resolved?.())
@@ -268,11 +273,12 @@ export async function runAgent(agent, initial, session, opts) {
268
273
  for (const [idx, tu] of toolCalls.entries()) {
269
274
  await emit({ type: "tool-started", source, toolUseId: tu.toolCallId, name: tu.toolName, input: tu.input, ...detailField(details[idx]) });
270
275
  }
271
- const { results, durations } = await dispatchTools(toolCalls, toolsById, toolCtx, isFatalLoopError, runStep, formatStepName.tool);
276
+ const { results, durations } = await dispatchTools(toolCalls, toolsById, toolCtx, isFatalLoopError, runStep, formatStepName.tool, encoding);
272
277
  const errored = await settleRound(toolCalls, results, details, durations);
273
278
  if (errored.length > 0)
274
279
  log.debug("tool results returned errors", { iteration: i, tools: errored });
275
280
  messages.push({ role: "tool", content: results });
281
+ appendDeferredImages(messages, results, encoding.deferredImages);
276
282
  if (hasDenial(results))
277
283
  return stopOnDenial(i);
278
284
  if (opts.resolved?.())
@@ -385,6 +391,50 @@ function markLastLoopAssistant(messages, initialCount) {
385
391
  }
386
392
  }
387
393
  }
394
+ /**
395
+ * Where a tool picture goes for this wire. The tool result wins, because it is
396
+ * the native place and the correlation to the tool call stays implicit. The user
397
+ * message is the fallback. When the wire renders neither, the loop drops the
398
+ * picture.
399
+ */
400
+ function imagePlacementFor(capabilities) {
401
+ if (capabilities.imageToolResults === true)
402
+ return "tool-result";
403
+ if (capabilities.imageUserMessages === true)
404
+ return "user-message";
405
+ return "drop";
406
+ }
407
+ /**
408
+ * Append one user message that carries each deferred picture of the round, then
409
+ * empty the collector.
410
+ *
411
+ * The tool message must come directly after the assistant message with the
412
+ * tool calls. Thus a picture rides a separate message after the whole tool
413
+ * message, and one message batches the round. The parts obey the order of
414
+ * `results`, which is the order of the tool calls. Each deferred picture has a
415
+ * result of this round, because the collector fills during the dispatch and it
416
+ * empties here. A text part names the tool call of each picture, because the wire
417
+ * holds no structural link between a user message and a tool call.
418
+ *
419
+ * The message carries the synthetic marker, because a `user` message opens a
420
+ * conversation turn. An unmarked one is loop machinery that reads as a turn
421
+ * boundary, and it splits one stored turn in two.
422
+ */
423
+ function appendDeferredImages(messages, results, deferredImages) {
424
+ if (deferredImages.length === 0)
425
+ return;
426
+ const byToolCallId = new Map(deferredImages.map((deferred) => [deferred.toolCallId, deferred]));
427
+ const content = [];
428
+ for (const result of results) {
429
+ const deferred = byToolCallId.get(result.toolCallId);
430
+ if (deferred === undefined)
431
+ continue;
432
+ content.push({ type: "text", text: `The picture of the tool result ${deferred.toolCallId} of ${deferred.toolName}.` });
433
+ content.push({ type: "file", mediaType: deferred.image.mediaType, data: { type: "data", data: deferred.image.base64 } });
434
+ }
435
+ messages.push(syntheticUserMessage(content));
436
+ deferredImages.length = 0;
437
+ }
388
438
  /**
389
439
  * Dispatch one round of tool calls, and measure the time of each call.
390
440
  *
@@ -395,7 +445,7 @@ function markLastLoopAssistant(messages, initialCount) {
395
445
  * wrapper. The wrapper is part of what the call cost, and a cached replay of a step
396
446
  * is genuinely fast. A bracket inside the step would report a body that did not run.
397
447
  */
398
- async function dispatchTools(toolUses, toolsById, toolCtx, isFatalLoopError, runStep, toolStepName) {
448
+ async function dispatchTools(toolUses, toolsById, toolCtx, isFatalLoopError, runStep, toolStepName, encoding) {
399
449
  const results = new Array(toolUses.length);
400
450
  // The array starts with holes, which read as `undefined`. The element type
401
451
  // says so, thus it agrees with what `settleRound` accepts. Every index is in
@@ -415,19 +465,19 @@ async function dispatchTools(toolUses, toolsById, toolCtx, isFatalLoopError, run
415
465
  }
416
466
  await Promise.all(stepTools.map(({ tu, idx }) => {
417
467
  const startedAt = performance.now();
418
- return runStep(toolStepName(tu.toolName, tu.toolCallId), () => dispatchTool(tu, toolsById, toolCtx(tu), isFatalLoopError)).then((r) => {
468
+ return runStep(toolStepName(tu.toolName, tu.toolCallId), () => dispatchTool(tu, toolsById, toolCtx(tu), isFatalLoopError, encoding)).then((r) => {
419
469
  durations[idx] = elapsedMs(startedAt);
420
470
  results[idx] = r;
421
471
  });
422
472
  }));
423
473
  for (const { tu, idx } of workflowTools) {
424
474
  const startedAt = performance.now();
425
- results[idx] = await dispatchTool(tu, toolsById, toolCtx(tu), isFatalLoopError);
475
+ results[idx] = await dispatchTool(tu, toolsById, toolCtx(tu), isFatalLoopError, encoding);
426
476
  durations[idx] = elapsedMs(startedAt);
427
477
  }
428
478
  for (const { tu, idx } of inlineTools) {
429
479
  const startedAt = performance.now();
430
- results[idx] = await dispatchTool(tu, toolsById, toolCtx(tu), isFatalLoopError);
480
+ results[idx] = await dispatchTool(tu, toolsById, toolCtx(tu), isFatalLoopError, encoding);
431
481
  durations[idx] = elapsedMs(startedAt);
432
482
  }
433
483
  return { results, durations };
@@ -436,14 +486,14 @@ async function dispatchTools(toolUses, toolsById, toolCtx, isFatalLoopError, run
436
486
  function elapsedMs(startedAt) {
437
487
  return Math.round(performance.now() - startedAt);
438
488
  }
439
- async function dispatchTool(tu, toolsById, ctx, isFatalLoopError) {
489
+ async function dispatchTool(tu, toolsById, ctx, isFatalLoopError, encoding) {
440
490
  const tool = toolsById.get(tu.toolName);
441
491
  if (tool === undefined) {
442
492
  return errorResult(tu, `unknown tool: ${tu.toolName}`);
443
493
  }
444
494
  const parsed = tool.inputSchema.safeParse(tu.input);
445
495
  if (parsed.success)
446
- return execute(tu, tool, parsed.data, ctx, isFatalLoopError);
496
+ return execute(tu, tool, parsed.data, ctx, isFatalLoopError, encoding);
447
497
  // A complete JSON argument can arrive as a string behind function-call
448
498
  // markup or a code fence. Repair only makes the schema reachable — the
449
499
  // repaired value is validated in full, and the tool's own semantic checks
@@ -451,15 +501,15 @@ async function dispatchTool(tu, toolsById, ctx, isFatalLoopError) {
451
501
  const repairedInput = repairToolInput(tu.input, parsed.error);
452
502
  const repaired = repairedInput === undefined ? undefined : tool.inputSchema.safeParse(repairedInput);
453
503
  if (repaired?.success === true)
454
- return execute(tu, tool, repaired.data, ctx, isFatalLoopError);
504
+ return execute(tu, tool, repaired.data, ctx, isFatalLoopError, encoding);
455
505
  return errorResult(tu, `input validation failed: ${formatZodIssues(parsed.error, tu.input)}`);
456
506
  }
457
- async function execute(tu, tool, input, ctx, isFatalLoopError) {
507
+ async function execute(tu, tool, input, ctx, isFatalLoopError, encoding) {
458
508
  try {
459
509
  const output = await tool.execute(input, ctx);
460
510
  if (output.isErr())
461
511
  return errorResult(tu, toolErrorContent(output.error));
462
- return successResult(tu, output.value);
512
+ return successResult(tu, output.value, encoding);
463
513
  }
464
514
  catch (err) {
465
515
  if (isFatalLoopError(err))
@@ -478,12 +528,51 @@ async function execute(tu, tool, input, ctx, isFatalLoopError) {
478
528
  function jsonValue(value) {
479
529
  return JSON.parse(JSON.stringify(value ?? null), (_key, v) => (typeof v === "string" ? stripNulCharacters(v) : v));
480
530
  }
481
- function successResult(toolCall, value) {
531
+ /**
532
+ * Encode a tool ok value onto a tool-result message. A value with no picture
533
+ * stays a plain JSON result, byte-identical to a value that never carried one.
534
+ *
535
+ * A value that carries a picture splits: the JSON data goes to a text part, and
536
+ * the bytes ride as an image content block. Thus the model sees the picture, and
537
+ * the JSON text holds no bytes. When the wire carries no picture, the block drops
538
+ * and the text stays, because base64 text floods the context and the model cannot
539
+ * see it. The drop rides the log, thus an operator sees that the picture did not
540
+ * reach the model.
541
+ *
542
+ * A wire that renders a picture on a user message only gets the fallback: the
543
+ * result keeps its JSON text, and the picture goes to the collector of the round.
544
+ * The round then sends the bytes after the tool message.
545
+ */
546
+ function successResult(toolCall, value, encoding) {
547
+ const jsonData = jsonValue(value);
548
+ const jsonOnly = {
549
+ type: "tool-result",
550
+ toolCallId: toolCall.toolCallId,
551
+ toolName: toolCall.toolName,
552
+ output: { type: "json", value: jsonData },
553
+ };
554
+ const image = readToolResultImage(value);
555
+ if (image === undefined)
556
+ return jsonOnly;
557
+ if (encoding.placement === "drop") {
558
+ encoding.log.warn("the wired provider carries no picture in a tool result, thus the picture is dropped", { toolName: toolCall.toolName });
559
+ return jsonOnly;
560
+ }
561
+ if (encoding.placement === "user-message") {
562
+ encoding.deferredImages.push({ toolCallId: toolCall.toolCallId, toolName: toolCall.toolName, image });
563
+ return jsonOnly;
564
+ }
482
565
  return {
483
566
  type: "tool-result",
484
567
  toolCallId: toolCall.toolCallId,
485
568
  toolName: toolCall.toolName,
486
- output: { type: "json", value: jsonValue(value) },
569
+ output: {
570
+ type: "content",
571
+ value: [
572
+ { type: "text", text: JSON.stringify(jsonData) },
573
+ { type: "file", mediaType: image.mediaType, data: { type: "data", data: image.base64 } },
574
+ ],
575
+ },
487
576
  };
488
577
  }
489
578
  // NUL is stripped from the prose BEFORE it is serialized: past the stringify it
@@ -1,4 +1,4 @@
1
- import { type ModelMessage } from "ai";
1
+ import { 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<{
@@ -50,8 +50,9 @@ export declare function markInterruptedMessage(message: ModelMessage): ModelMess
50
50
  /** Whether a client abort cut off this message's production (see {@link markInterruptedMessage}); an absent key reads as not interrupted. */
51
51
  export declare function isInterruptedMessage(message: ModelMessage): boolean;
52
52
  /**
53
- * Build a `user` message the LOOP synthesized rather than a human typing it — today, the nudge that
54
- * continues a reply the model truncated at its output-token limit.
53
+ * Build a `user` message the LOOP synthesized rather than a human typing it — the nudge that
54
+ * continues a reply the model truncated at its output-token limit, and the message that carries a
55
+ * tool picture that the tool result cannot carry.
55
56
  *
56
57
  * It has to carry the `user` role because the wire format requires a user turn after a truncated
57
58
  * assistant message, but it is not user input, and the difference is load-bearing: a `user` message is
@@ -59,11 +60,15 @@ export declare function isInterruptedMessage(message: ModelMessage): boolean;
59
60
  * splitting one turn into two for the token window and, worse, giving a tail-turn removal a cut point in
60
61
  * the middle of a turn. Marking it is what lets {@link isSyntheticUserMessage} keep those readers honest.
61
62
  *
63
+ * `content` takes prose or the parts of a user message, because a picture rides a file part and no
64
+ * string can carry one. The marker is the same either way: what makes a message synthetic is who wrote
65
+ * it, not what it holds.
66
+ *
62
67
  * This one is loop MACHINERY and is not rendered: showing it would put words on screen nobody typed.
63
68
  * An embedder recording out-of-band work — an analysis run's outcome — wants
64
69
  * {@link syntheticRecordMessage} instead, which is equally non-turn-opening but IS displayed.
65
70
  */
66
- export declare function syntheticUserMessage(text: string): ModelMessage;
71
+ export declare function syntheticUserMessage(content: UserContent): ModelMessage;
67
72
  /**
68
73
  * Build a record of out-of-band work for an embedder to append between turns — an analysis run's
69
74
  * outcome, which the model should read on its next turn but which nobody said.
@@ -73,8 +73,9 @@ export function isInterruptedMessage(message) {
73
73
  return message.providerOptions?.[HARNESS_PROVIDER_NAMESPACE]?.[INTERRUPTED_MESSAGE_KEY] === true;
74
74
  }
75
75
  /**
76
- * Build a `user` message the LOOP synthesized rather than a human typing it — today, the nudge that
77
- * continues a reply the model truncated at its output-token limit.
76
+ * Build a `user` message the LOOP synthesized rather than a human typing it — the nudge that
77
+ * continues a reply the model truncated at its output-token limit, and the message that carries a
78
+ * tool picture that the tool result cannot carry.
78
79
  *
79
80
  * It has to carry the `user` role because the wire format requires a user turn after a truncated
80
81
  * assistant message, but it is not user input, and the difference is load-bearing: a `user` message is
@@ -82,14 +83,18 @@ export function isInterruptedMessage(message) {
82
83
  * splitting one turn into two for the token window and, worse, giving a tail-turn removal a cut point in
83
84
  * the middle of a turn. Marking it is what lets {@link isSyntheticUserMessage} keep those readers honest.
84
85
  *
86
+ * `content` takes prose or the parts of a user message, because a picture rides a file part and no
87
+ * string can carry one. The marker is the same either way: what makes a message synthetic is who wrote
88
+ * it, not what it holds.
89
+ *
85
90
  * This one is loop MACHINERY and is not rendered: showing it would put words on screen nobody typed.
86
91
  * An embedder recording out-of-band work — an analysis run's outcome — wants
87
92
  * {@link syntheticRecordMessage} instead, which is equally non-turn-opening but IS displayed.
88
93
  */
89
- export function syntheticUserMessage(text) {
94
+ export function syntheticUserMessage(content) {
90
95
  return {
91
96
  role: "user",
92
- content: text,
97
+ content,
93
98
  providerOptions: { [HARNESS_PROVIDER_NAMESPACE]: { [SYNTHETIC_MESSAGE_KEY]: true } },
94
99
  };
95
100
  }
@@ -17,10 +17,39 @@ function enc() {
17
17
  return encoder;
18
18
  }
19
19
  /**
20
- * The token-bearing text of one content block. Text-carrying fields are
21
- * extracted directly; structural blocks (`tool_use` input, `tool_result`
22
- * payload) are JSON-stringified. A signed `thinking` block counts only its
23
- * reasoning text — the opaque `signature` is metadata, not prompt tokens.
20
+ * The token-bearing text of one tool-result output.
21
+ *
22
+ * A `content` output holds the text parts and the attachments side by side. Only
23
+ * the text is prompt text. A file rides the wire as an attachment, and the
24
+ * provider bills it at its own rate, thus its bytes count as `0` — the same rule
25
+ * as a top-level `file` block. A file that is stringified into the count inflates
26
+ * the row of the message by tens of thousands of tokens, and the load window then
27
+ * drops the true history.
28
+ *
29
+ * Each other output arm is plain JSON, thus it is stringified whole.
30
+ */
31
+ function toolResultText(output) {
32
+ if (typeof output !== "object" || output === null)
33
+ return JSON.stringify(output ?? {});
34
+ const out = output;
35
+ if (out.type !== "content" || !Array.isArray(out.value))
36
+ return JSON.stringify(out);
37
+ const texts = [];
38
+ for (const item of out.value) {
39
+ if (typeof item !== "object" || item === null)
40
+ continue;
41
+ const nested = item;
42
+ if (nested.type === "text" && typeof nested.text === "string")
43
+ texts.push(nested.text);
44
+ }
45
+ return texts.join(" ");
46
+ }
47
+ /**
48
+ * The token-bearing text of one content block. A text-carrying field is
49
+ * extracted directly. A `tool_use` input is JSON-stringified, and a
50
+ * `tool_result` output goes through {@link toolResultText}. A signed `thinking`
51
+ * block counts only its reasoning text, because the opaque `signature` is
52
+ * metadata and not prompt tokens.
24
53
  */
25
54
  function tokenizableText(block) {
26
55
  if (typeof block === "string")
@@ -40,7 +69,7 @@ function tokenizableText(block) {
40
69
  case "tool-call":
41
70
  return `${String(part.toolName ?? "")} ${JSON.stringify(part.input ?? {})}`;
42
71
  case "tool-result": {
43
- return `${String(part.toolName ?? "")} ${JSON.stringify(part.output ?? {})}`;
72
+ return `${String(part.toolName ?? "")} ${toolResultText(part.output)}`;
44
73
  }
45
74
  default:
46
75
  return JSON.stringify(part);
@@ -22,10 +22,10 @@
22
22
  * row and the thread reads as it did; a listing widened with `includeArchived`
23
23
  * is the only way to obtain the id it takes, so the recovery is reachable by a
24
24
  * host that holds no thread ids it obtained elsewhere.
25
- * `purgeThread` is not recoverable: it removes the `messages` rows and the
26
- * metadata rows of the whole subtree in one transaction, so a failure partway
27
- * leaves both — never a thread stripped of its transcript, nor a transcript
28
- * with nothing naming it. It is the thread-scoped member of this package's
25
+ * `purgeThread` is not recoverable: it removes the `messages` rows, the report
26
+ * session-state rows, and the metadata rows of the whole subtree in one
27
+ * transaction, so a failure partway leaves them all — never a thread stripped of
28
+ * its transcript, nor a transcript with nothing naming it. It is the thread-scoped member of this package's
29
29
  * reclamation vocabulary — `purgeAnalysis` reclaims an analysis's whole
30
30
  * persisted footprint, `purgeThread` one subtree's.
31
31
  *
@@ -22,10 +22,10 @@
22
22
  * row and the thread reads as it did; a listing widened with `includeArchived`
23
23
  * is the only way to obtain the id it takes, so the recovery is reachable by a
24
24
  * host that holds no thread ids it obtained elsewhere.
25
- * `purgeThread` is not recoverable: it removes the `messages` rows and the
26
- * metadata rows of the whole subtree in one transaction, so a failure partway
27
- * leaves both — never a thread stripped of its transcript, nor a transcript
28
- * with nothing naming it. It is the thread-scoped member of this package's
25
+ * `purgeThread` is not recoverable: it removes the `messages` rows, the report
26
+ * session-state rows, and the metadata rows of the whole subtree in one
27
+ * transaction, so a failure partway leaves them all — never a thread stripped of
28
+ * its transcript, nor a transcript with nothing naming it. It is the thread-scoped member of this package's
29
29
  * reclamation vocabulary — `purgeAnalysis` reclaims an analysis's whole
30
30
  * persisted footprint, `purgeThread` one subtree's.
31
31
  *
@@ -262,10 +262,10 @@ export function createThreadStore(pool) {
262
262
  WHERE thread_id = $1 AND deleted_at IS NOT NULL`, [threadId])).map(() => undefined);
263
263
  }
264
264
  function purgeThread(threadId) {
265
- // One transaction for both statements: `messages` is attributable to an
265
+ // One transaction for every statement: `messages` is attributable to an
266
266
  // analysis only by joining through `cortex_analysis_threads`, so a metadata
267
267
  // row removed without its messages strands them beyond the reach of any
268
- // later reclamation. Either the pair goes or neither does.
268
+ // later reclamation. Either the whole set goes or none of it does.
269
269
  //
270
270
  // The explicit message delete must reach the same depth the database
271
271
  // cascade reaches. The cascade on `parent_thread_id` removes a
@@ -274,11 +274,20 @@ export function createThreadStore(pool) {
274
274
  // would strand a grandchild's transcript exactly as a bare cascade strands
275
275
  // a child's.
276
276
  //
277
- // Messages go first: the walk both statements share reads the very rows
278
- // the second one removes, so the reverse order would leave the subtree
279
- // unresolvable by the time its transcripts were named.
277
+ // The session state of a report thread goes the same way, and for the same
278
+ // reason: `cortex_report_session_state` carries no foreign key to
279
+ // `cortex_analysis_threads` either, so the cascade cannot reach it. Its row
280
+ // holds a whole draft document, and a purged thread can never be composed
281
+ // again, so a row left behind is dead weight until the analysis purge runs.
282
+ //
283
+ // Messages go first, and the thread rows go last: the walk every statement
284
+ // shares reads the very rows the last one removes. Thus a thread delete before
285
+ // the others leaves the subtree unresolvable before a later statement names its
286
+ // transcripts and its drafts.
280
287
  return withTransaction(pool, "thread-store.purgeThread", (client) => tryMutation("thread-store.purgeThread.messages", () => client.query(`${SUBTREE_CTE}
281
288
  DELETE FROM messages WHERE thread_id IN (SELECT thread_id FROM subtree)`, [threadId]))
289
+ .andThen(() => tryMutation("thread-store.purgeThread.reportSessionState", () => client.query(`${SUBTREE_CTE}
290
+ DELETE FROM cortex_report_session_state WHERE thread_id IN (SELECT thread_id FROM subtree)`, [threadId])))
282
291
  .andThen(() => tryMutation("thread-store.purgeThread.thread", () => client.query(`${SUBTREE_CTE}
283
292
  DELETE FROM cortex_analysis_threads WHERE thread_id IN (SELECT thread_id FROM subtree)`, [threadId])))
284
293
  .map(() => undefined));