@inflexa-ai/harness 0.19.0 → 0.19.2

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 (101) hide show
  1. package/dist/agents/report-session-agent.d.ts +32 -9
  2. package/dist/agents/report-session-agent.js +22 -6
  3. package/dist/app/report-session-runtime.js +26 -0
  4. package/dist/app/spawn-report-session.d.ts +32 -5
  5. package/dist/app/spawn-report-session.js +19 -0
  6. package/dist/auth/local-run-authorizer.d.ts +3 -2
  7. package/dist/auth/local-run-authorizer.js +4 -2
  8. package/dist/config/environment-stores.d.ts +12 -7
  9. package/dist/config/resource-limits.d.ts +6 -10
  10. package/dist/config/resource-limits.js +6 -44
  11. package/dist/contracts/report-reference.d.ts +4 -0
  12. package/dist/contracts/report-reference.js +3 -0
  13. package/dist/execution/run-authorizer.d.ts +18 -0
  14. package/dist/execution/run-canceler.d.ts +67 -0
  15. package/dist/execution/run-canceler.js +130 -0
  16. package/dist/index.d.ts +7 -3
  17. package/dist/index.js +6 -1
  18. package/dist/lib/chrome.d.ts +14 -0
  19. package/dist/lib/chrome.js +7 -0
  20. package/dist/lib/page-capture.d.ts +47 -0
  21. package/dist/lib/page-capture.js +83 -0
  22. package/dist/loop/run-agent.js +104 -15
  23. package/dist/memory/ai-sdk-message-storage.d.ts +9 -4
  24. package/dist/memory/ai-sdk-message-storage.js +9 -4
  25. package/dist/memory/count-tokens.js +34 -5
  26. package/dist/prompts/report-session.d.ts +1 -1
  27. package/dist/prompts/report-session.js +21 -0
  28. package/dist/providers/ai-sdk.d.ts +81 -3
  29. package/dist/providers/ai-sdk.js +91 -10
  30. package/dist/providers/types.d.ts +17 -0
  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/production-resolver.d.ts +124 -0
  37. package/dist/report-model/production-resolver.js +801 -0
  38. package/dist/report-model/reference-resolver.d.ts +6 -0
  39. package/dist/report-model/validate.js +5 -0
  40. package/dist/report-render/assets.d.ts +42 -0
  41. package/dist/report-render/assets.js +61 -0
  42. package/dist/report-render/chart.js +31 -10
  43. package/dist/report-render/design.d.ts +120 -0
  44. package/dist/report-render/design.js +808 -0
  45. package/dist/report-render/page.d.ts +51 -111
  46. package/dist/report-render/page.js +158 -268
  47. package/dist/report-render/render.js +34 -11
  48. package/dist/report-render/views/chart-view.d.ts +6 -3
  49. package/dist/report-render/views/chart-view.js +10 -5
  50. package/dist/report-render/views/page-view.d.ts +19 -8
  51. package/dist/report-render/views/page-view.js +31 -11
  52. package/dist/report-render/views/prose.d.ts +1 -0
  53. package/dist/report-render/views/prose.js +7 -13
  54. package/dist/report-render/views/references-view.js +5 -5
  55. package/dist/report-render/views/values.d.ts +15 -6
  56. package/dist/report-render/views/values.js +29 -10
  57. package/dist/runtime/assemble.d.ts +38 -4
  58. package/dist/runtime/assemble.js +43 -3
  59. package/dist/sandbox/k8s-client.js +18 -0
  60. package/dist/sandbox/mount-plan.d.ts +7 -0
  61. package/dist/sandbox/mount-plan.js +7 -2
  62. package/dist/sandbox/types.d.ts +6 -0
  63. package/dist/state/analyses.d.ts +3 -26
  64. package/dist/state/analyses.js +6 -64
  65. package/dist/state/index.d.ts +2 -2
  66. package/dist/state/index.js +2 -2
  67. package/dist/state/init.js +23 -10
  68. package/dist/state/report-session-state.d.ts +27 -0
  69. package/dist/state/report-session-state.js +29 -2
  70. package/dist/state/runs.d.ts +10 -0
  71. package/dist/state/runs.js +21 -0
  72. package/dist/tasks/data-profile.d.ts +4 -0
  73. package/dist/tasks/data-profile.js +14 -0
  74. package/dist/tasks/extract-values-script.d.ts +64 -0
  75. package/dist/tasks/extract-values-script.js +180 -0
  76. package/dist/tasks/extract-values.d.ts +124 -0
  77. package/dist/tasks/extract-values.js +193 -0
  78. package/dist/tools/approval/gateway.d.ts +8 -1
  79. package/dist/tools/approval/gateway.js +9 -2
  80. package/dist/tools/approval/queries.d.ts +9 -2
  81. package/dist/tools/approval/queries.js +12 -4
  82. package/dist/tools/define-tool.d.ts +29 -0
  83. package/dist/tools/define-tool.js +26 -0
  84. package/dist/tools/report/preview-snapshot.d.ts +1 -1
  85. package/dist/tools/report/preview-snapshot.js +11 -50
  86. package/dist/tools/report-authoring/authoring-tools.d.ts +54 -4
  87. package/dist/tools/report-authoring/authoring-tools.js +1 -1
  88. package/dist/tools/report-session/examine-page.d.ts +90 -0
  89. package/dist/tools/report-session/examine-page.js +136 -0
  90. package/dist/tools/report-session/index.d.ts +3 -1
  91. package/dist/tools/report-session/index.js +3 -1
  92. package/dist/tools/report-session/preview-report.d.ts +31 -6
  93. package/dist/tools/report-session/preview-report.js +63 -16
  94. package/dist/tools/report-session/record-version.d.ts +102 -0
  95. package/dist/tools/report-session/record-version.js +139 -0
  96. package/dist/tools/sandbox/list-available-packages.js +2 -1
  97. package/dist/tools/sandbox/list-available-refs.d.ts +5 -1
  98. package/dist/tools/sandbox/list-available-refs.js +13 -6
  99. package/dist/workflows/sandbox-step.d.ts +4 -0
  100. package/dist/workflows/sandbox-step.js +21 -2
  101. package/package.json +14 -8
@@ -0,0 +1,130 @@
1
+ /**
2
+ * RunCanceler — the encapsulated external-cancel path for an `executeAnalysis` run.
3
+ *
4
+ * A cancelled DBOS workflow can never execute another step, so the body's own
5
+ * terminal block (`collectAndComplete`) is unreachable on external cancel and
6
+ * nothing converges: the run row stays `running` (blocking same-plan relaunch
7
+ * through `queryActiveRun`'s active-status dedup), pending step rows never
8
+ * sweep, the running charge never closes, and the run mandate is never
9
+ * revoked. This module is the host-side counterpart: cancel the engine's
10
+ * parent + children, then converge each ledger the workflow would have.
11
+ *
12
+ * The engine capability stays internal — the production default is the
13
+ * purger's `DBOSClient`-backed child-cascading cancel, which needs no launched
14
+ * engine (see `dbos-workflow-purger.ts` for why the static `DBOS` facade is
15
+ * the wrong tool here). `cancelWorkflows` is an optional test seam over it.
16
+ *
17
+ * No stream writes: `DBOS.writeStream` is body-only, so hosts synthesize the
18
+ * terminal frame read-side.
19
+ */
20
+ import { createNoopLogger } from "../lib/console-logger.js";
21
+ import { unwrapOrThrow } from "../lib/result.js";
22
+ import { markRunCanceledIfActive, queryRun } from "../state/runs.js";
23
+ import { queryStepsByRun, sweepPendingStepExecutions } from "../state/step-executions.js";
24
+ import { createDbosWorkflowPurger } from "./dbos-workflow-purger.js";
25
+ /** The reason stamped on the run row and the revoke — the same literal
26
+ * `collectAndComplete` records for a cancel it observes from inside. */
27
+ const EXTERNAL_CANCEL_REASON = "external_cancel";
28
+ export class UnknownRunError extends Error {
29
+ runId;
30
+ constructor(runId) {
31
+ super(`no run exists for id ${runId}`);
32
+ this.runId = runId;
33
+ this.name = "UnknownRunError";
34
+ }
35
+ }
36
+ const TERMINAL = new Set(["completed", "partial", "failed", "canceled"]);
37
+ export function createRunCanceler(deps) {
38
+ const { pool, runCharge, runAuthorizer } = deps;
39
+ const logger = (deps.logger ?? createNoopLogger()).named("run-canceler");
40
+ // Lazy so a canceler built for a host that never cancels constructs no client;
41
+ // one purger per factory (its client registers pool handlers — see its comment).
42
+ let engineCancel = deps.cancelWorkflows;
43
+ const cancelWorkflows = async (workflowIds) => {
44
+ engineCancel ??= (() => {
45
+ const purger = createDbosWorkflowPurger({ pool, logger });
46
+ return async (ids) => {
47
+ unwrapOrThrow(await purger.cancel([...ids]));
48
+ };
49
+ })();
50
+ await engineCancel(workflowIds);
51
+ };
52
+ /** Persisted child workflow ids of steps not yet completed. */
53
+ const incompleteChildIds = async (runId) => unwrapOrThrow(await queryStepsByRun(pool, runId)).flatMap((step) => step.childWorkflowId !== null && step.completedAt === null ? [step.childWorkflowId] : []);
54
+ return {
55
+ async cancel(runId, session) {
56
+ const row = unwrapOrThrow(await queryRun(pool, runId));
57
+ if (row === null)
58
+ throw new UnknownRunError(runId);
59
+ const workflowId = runId;
60
+ if (TERMINAL.has(row.status)) {
61
+ return {
62
+ runId,
63
+ workflowId,
64
+ outcome: "already_terminal",
65
+ finalStatus: row.status,
66
+ converged: { steps: false, charge: false, mandate: false },
67
+ };
68
+ }
69
+ // The child-cascading cancel walks the engine's own parent ledger, which
70
+ // has a row the moment a child starts — that covers a child whose
71
+ // mark-running step has not yet committed its child_workflow_id. The
72
+ // persisted ids and the post-cancel re-query are belt-and-braces over
73
+ // both ledgers' lag.
74
+ const knownChildren = await incompleteChildIds(runId);
75
+ await cancelWorkflows([workflowId, ...knownChildren]);
76
+ try {
77
+ const late = (await incompleteChildIds(runId)).filter((id) => !knownChildren.includes(id));
78
+ if (late.length > 0)
79
+ await cancelWorkflows(late);
80
+ }
81
+ catch (err) {
82
+ logger.error("late-child cancel sweep failed", { runId, ...logger.errorFields(err) });
83
+ }
84
+ // Convergence — each phase isolated so one failure skips nothing after it.
85
+ let transitioned = false;
86
+ try {
87
+ transitioned = unwrapOrThrow(await markRunCanceledIfActive(pool, runId, EXTERNAL_CANCEL_REASON));
88
+ }
89
+ catch (err) {
90
+ logger.error("markRunCanceledIfActive failed", { runId, ...logger.errorFields(err) });
91
+ }
92
+ let steps = false;
93
+ try {
94
+ unwrapOrThrow(await sweepPendingStepExecutions(pool, runId));
95
+ steps = true;
96
+ }
97
+ catch (err) {
98
+ logger.error("pending-step sweep failed", { runId, ...logger.errorFields(err) });
99
+ }
100
+ // Best-effort: the billing authority self-heals (defensive open + stale
101
+ // reaper), so a failed close loses attribution only.
102
+ let charge = false;
103
+ try {
104
+ await runCharge.close({ analysisId: row.analysisId, runId, reason: "canceled", session });
105
+ charge = true;
106
+ }
107
+ catch (err) {
108
+ logger.error("running-charge close failed", { runId, ...logger.errorFields(err) });
109
+ }
110
+ let mandate = row.mandateJti === null;
111
+ if (row.mandateJti !== null) {
112
+ try {
113
+ await runAuthorizer.revokeByJti({ jti: row.mandateJti, auth: session.auth }, EXTERNAL_CANCEL_REASON);
114
+ mandate = true;
115
+ }
116
+ catch (err) {
117
+ logger.error("mandate revoke failed", { runId, jti: row.mandateJti, ...logger.errorFields(err) });
118
+ }
119
+ }
120
+ // No transition means the run reached a terminal state on its own (or the
121
+ // write failed) — report the row as it stands rather than claiming `canceled`.
122
+ let finalStatus = "canceled";
123
+ if (!transitioned) {
124
+ const reread = unwrapOrThrow(await queryRun(pool, runId));
125
+ finalStatus = reread?.status ?? "canceled";
126
+ }
127
+ return { runId, workflowId, outcome: "canceled", finalStatus, converged: { steps, charge, mandate } };
128
+ },
129
+ };
130
+ }
package/dist/index.d.ts CHANGED
@@ -5,14 +5,16 @@
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";
10
+ export type { ResolvePageAsset } from "./tools/report-session/index.js";
9
11
  export type { ThreadAgentResolver, UnregisteredThreadType } from "./runtime/assemble.js";
10
12
  export { bootHarness } from "./runtime/boot.js";
11
13
  export type { BootHarnessDeps, BootedHarness } from "./runtime/boot.js";
12
14
  export { createConversationAgent } from "./agents/conversation-agent.js";
13
15
  export type { ConversationAgentDeps } from "./agents/conversation-agent.js";
14
16
  export { createLocalRunAuthorizer } from "./auth/local-run-authorizer.js";
15
- export type { RunAuthorizer, AuthorizeRunInput, RunAuthorization } from "./execution/run-authorizer.js";
17
+ export type { RunAuthorizer, AuthorizeRunInput, RunAuthorization, RevokeByJtiRef } from "./execution/run-authorizer.js";
16
18
  export { createNoopBillingResolver } from "./billing/noop-resolver.js";
17
19
  export type { ResolveBilling, ResolvableSession, BillingMap, BillingHeaders, BillingFetchResult } from "./billing/resolver.js";
18
20
  export { createNoopRunCharge } from "./billing/noop-run-charge.js";
@@ -30,6 +32,8 @@ export type { RunLauncher, LaunchOptions } from "./execution/run-launcher.js";
30
32
  export { createDbosWorkflowPurger } from "./execution/dbos-workflow-purger.js";
31
33
  export type { DbosWorkflowPurgerDeps } from "./execution/dbos-workflow-purger.js";
32
34
  export type { WorkflowPurger } from "./execution/workflow-purger.js";
35
+ export { createRunCanceler, UnknownRunError } from "./execution/run-canceler.js";
36
+ export type { RunCanceler, RunCancelerDeps, CancelRunResult } from "./execution/run-canceler.js";
33
37
  export { defineTool, isToolError } from "./tools/define-tool.js";
34
38
  export type { Tool, ToolDefinition, ToolContext, ToolError } from "./tools/define-tool.js";
35
39
  export { normalizeDetail, DETAIL_MAX_LENGTH } from "./loop/tool-detail.js";
@@ -136,7 +140,7 @@ export type { ValidatePlanOptions } from "./schemas/validate-plan.js";
136
140
  export type { ValidationResult } from "./schemas/validate-plan.js";
137
141
  export { upsertPlan, loadPlan } from "./state/plans.js";
138
142
  export type { UpsertPlanInput } from "./state/plans.js";
139
- export { insertRun, reserveRunById, queryActiveRun, queryActiveRunsByAnalysis, updateRunStatus, queryRun, queryRunsByAnalysis, RunDedupCollisionError, RunIdentityCollisionError, } from "./state/runs.js";
143
+ export { insertRun, reserveRunById, queryActiveRun, queryActiveRunsByAnalysis, updateRunStatus, markRunCanceledIfActive, queryRun, queryRunsByAnalysis, RunDedupCollisionError, RunIdentityCollisionError, } from "./state/runs.js";
140
144
  export type { InsertRunInput, RunReservation } from "./state/runs.js";
141
145
  export { queryStepsByRun } from "./state/step-executions.js";
142
146
  export type { CortexRunRow, StepExecutionRow, RunStatus } from "./state/schema.js";
package/dist/index.js CHANGED
@@ -31,6 +31,11 @@ export { createDbosRunLauncher } from "./execution/dbos-run-launcher.js";
31
31
  // Seam: workflow-ledger reclamation — cancels and deletes an analysis's rows in
32
32
  // the durability engine's own ledger, so no caller names an engine type.
33
33
  export { createDbosWorkflowPurger } from "./execution/dbos-workflow-purger.js";
34
+ // Run cancellation — the encapsulated external-cancel path: engine cancel
35
+ // (parent + children) plus convergence of the run row, pending step rows,
36
+ // running charge, and run mandate. Hosts call this from their cancel route
37
+ // instead of hand-rolling `DBOS.cancelWorkflow` + partial cleanup.
38
+ export { createRunCanceler, UnknownRunError } from "./execution/run-canceler.js";
34
39
  // Tool primitive.
35
40
  export { defineTool, isToolError } from "./tools/define-tool.js";
36
41
  // Tool-call-detail authoring aids for an embedder that contributes a host tool.
@@ -224,7 +229,7 @@ export { validatePlan } from "./schemas/validate-plan.js";
224
229
  // caller-derived id and inserts-if-absent (the deterministic-id intake path);
225
230
  // the run-state calls mirror the chat trigger's reserve → dedup → status flow.
226
231
  export { upsertPlan, loadPlan } from "./state/plans.js";
227
- export { insertRun, reserveRunById, queryActiveRun, queryActiveRunsByAnalysis, updateRunStatus, queryRun, queryRunsByAnalysis, RunDedupCollisionError, RunIdentityCollisionError, } from "./state/runs.js";
232
+ export { insertRun, reserveRunById, queryActiveRun, queryActiveRunsByAnalysis, updateRunStatus, markRunCanceledIfActive, queryRun, queryRunsByAnalysis, RunDedupCollisionError, RunIdentityCollisionError, } from "./state/runs.js";
228
233
  export { queryStepsByRun } from "./state/step-executions.js";
229
234
  // Host-neutral reference-data catalog and shared installation receipts. Each
230
235
  // artifact names the third-party upstream it is fetched from; embedders own all
@@ -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.