@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.
- package/dist/agents/report-session-agent.d.ts +72 -0
- package/dist/agents/report-session-agent.js +87 -0
- package/dist/app/report-session-runtime.d.ts +72 -0
- package/dist/app/report-session-runtime.js +183 -0
- package/dist/app/spawn-report-session.d.ts +32 -5
- package/dist/app/spawn-report-session.js +19 -0
- package/dist/config/environment-stores.d.ts +12 -7
- package/dist/config/resource-limits.d.ts +6 -10
- package/dist/config/resource-limits.js +6 -44
- package/dist/contracts/report-reference.d.ts +4 -0
- package/dist/contracts/report-reference.js +3 -0
- package/dist/index.d.ts +2 -1
- package/dist/lib/chrome.d.ts +14 -0
- package/dist/lib/chrome.js +7 -0
- package/dist/lib/page-capture.d.ts +47 -0
- package/dist/lib/page-capture.js +83 -0
- package/dist/loop/run-agent.js +104 -15
- package/dist/memory/ai-sdk-message-storage.d.ts +9 -4
- package/dist/memory/ai-sdk-message-storage.js +9 -4
- package/dist/memory/count-tokens.js +34 -5
- package/dist/memory/thread-store.d.ts +4 -4
- package/dist/memory/thread-store.js +18 -9
- package/dist/prompts/report-session.d.ts +1 -0
- package/dist/prompts/report-session.js +110 -0
- package/dist/providers/ai-sdk.d.ts +158 -3
- package/dist/providers/ai-sdk.js +234 -23
- package/dist/providers/errors.d.ts +14 -0
- package/dist/providers/errors.js +103 -18
- package/dist/providers/types.d.ts +33 -0
- package/dist/providers/types.js +10 -1
- package/dist/report-model/assert-rules.d.ts +44 -0
- package/dist/report-model/assert-rules.js +97 -0
- package/dist/report-model/draft-hash.d.ts +14 -0
- package/dist/report-model/draft-hash.js +36 -0
- package/dist/report-model/fixture-resolver.js +2 -71
- package/dist/report-model/{mint-snapshot.d.ts → pin-snapshot.d.ts} +7 -7
- package/dist/report-model/{mint-snapshot.js → pin-snapshot.js} +7 -7
- package/dist/report-model/production-resolver.d.ts +124 -0
- package/dist/report-model/production-resolver.js +801 -0
- package/dist/report-model/reference-resolver.d.ts +7 -1
- package/dist/report-model/validate.d.ts +34 -2
- package/dist/report-model/validate.js +37 -17
- package/dist/report-render/page.d.ts +23 -0
- package/dist/report-render/page.js +36 -3
- package/dist/report-render/value-bridge.d.ts +62 -0
- package/dist/report-render/value-bridge.js +82 -0
- package/dist/runtime/assemble.d.ts +39 -0
- package/dist/runtime/assemble.js +67 -6
- package/dist/sandbox/mount-plan.d.ts +7 -0
- package/dist/sandbox/mount-plan.js +7 -2
- package/dist/state/init.js +36 -0
- package/dist/state/purge-analysis.js +1 -0
- package/dist/state/report-session-state.d.ts +139 -0
- package/dist/state/report-session-state.js +177 -0
- package/dist/state/report-versions.d.ts +3 -6
- package/dist/state/report-versions.js +11 -38
- package/dist/state/snapshot-parse.d.ts +34 -0
- package/dist/state/snapshot-parse.js +82 -0
- package/dist/tasks/extract-values-script.d.ts +64 -0
- package/dist/tasks/extract-values-script.js +180 -0
- package/dist/tasks/extract-values.d.ts +124 -0
- package/dist/tasks/extract-values.js +193 -0
- package/dist/tools/ad-hoc-router.d.ts +7 -2
- package/dist/tools/ad-hoc-router.js +2 -1
- package/dist/tools/define-tool.d.ts +29 -0
- package/dist/tools/define-tool.js +26 -0
- package/dist/tools/report/preview-snapshot.d.ts +1 -1
- package/dist/tools/report/preview-snapshot.js +11 -50
- package/dist/tools/report-authoring/authoring-tools.d.ts +181 -24
- package/dist/tools/report-authoring/authoring-tools.js +152 -44
- package/dist/tools/report-session/examine-page.d.ts +90 -0
- package/dist/tools/report-session/examine-page.js +136 -0
- package/dist/tools/report-session/index.d.ts +10 -0
- package/dist/tools/report-session/index.js +10 -0
- package/dist/tools/report-session/preview-report.d.ts +105 -0
- package/dist/tools/report-session/preview-report.js +269 -0
- package/dist/tools/report-session/record-version.d.ts +102 -0
- package/dist/tools/report-session/record-version.js +139 -0
- package/dist/tools/research/generate-plan.js +4 -2
- package/dist/tools/sandbox/list-available-packages.js +2 -1
- package/dist/tools/sandbox/list-available-refs.d.ts +5 -1
- package/dist/tools/sandbox/list-available-refs.js +13 -6
- 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";
|
package/dist/lib/chrome.d.ts
CHANGED
|
@@ -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>;
|
package/dist/lib/chrome.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/loop/run-agent.js
CHANGED
|
@@ -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
|
-
|
|
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: {
|
|
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 —
|
|
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(
|
|
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 —
|
|
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(
|
|
94
|
+
export function syntheticUserMessage(content) {
|
|
90
95
|
return {
|
|
91
96
|
role: "user",
|
|
92
|
-
content
|
|
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
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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 ?? "")} ${
|
|
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
|
|
26
|
-
* metadata rows of the whole subtree in one
|
|
27
|
-
* leaves
|
|
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
|
|
26
|
-
* metadata rows of the whole subtree in one
|
|
27
|
-
* leaves
|
|
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
|
|
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
|
|
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
|
-
//
|
|
278
|
-
//
|
|
279
|
-
//
|
|
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));
|