@inflexa-ai/harness 0.22.1 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/README.md +1 -2
  2. package/dist/agents/conversation-agent.d.ts +0 -9
  3. package/dist/app/chat-turn.js +1 -0
  4. package/dist/app/message-assembly.d.ts +3 -0
  5. package/dist/app/message-assembly.js +17 -0
  6. package/dist/contracts/chat-parts.d.ts +38 -26
  7. package/dist/contracts/content-url.d.ts +7 -41
  8. package/dist/contracts/content-url.js +7 -48
  9. package/dist/contracts/index.d.ts +2 -2
  10. package/dist/contracts/index.js +1 -1
  11. package/dist/contracts/message.d.ts +1 -1
  12. package/dist/contracts/message.js +1 -1
  13. package/dist/contracts/part-registry.js +2 -2
  14. package/dist/contracts/schemas/chat-parts.d.ts +16 -44
  15. package/dist/contracts/schemas/chat-parts.js +15 -18
  16. package/dist/contracts/schemas/index.d.ts +1 -1
  17. package/dist/contracts/schemas/index.js +1 -1
  18. package/dist/execution/run-authorizer.d.ts +1 -1
  19. package/dist/execution/run-authorizer.js +1 -1
  20. package/dist/index.d.ts +1 -3
  21. package/dist/index.js +0 -2
  22. package/dist/lib/zod-issues.d.ts +2 -3
  23. package/dist/lib/zod-issues.js +2 -3
  24. package/dist/loop/__fixtures__/scripted-provider.js +3 -1
  25. package/dist/loop/run-agent.d.ts +5 -0
  26. package/dist/loop/run-agent.js +50 -2
  27. package/dist/loop/run-to-terminal.d.ts +1 -1
  28. package/dist/loop/run-to-terminal.js +1 -1
  29. package/dist/memory/card-builders.d.ts +0 -27
  30. package/dist/memory/card-builders.js +0 -75
  31. package/dist/memory/conversation-display-backfill.d.ts +2 -4
  32. package/dist/memory/conversation-display-backfill.js +3 -9
  33. package/dist/memory/conversation-display-storage.d.ts +3 -3
  34. package/dist/memory/conversation-display-storage.js +3 -3
  35. package/dist/memory/reconstruct-cards.d.ts +5 -7
  36. package/dist/memory/reconstruct-cards.js +6 -20
  37. package/dist/memory/tool-call-integrity.d.ts +46 -0
  38. package/dist/memory/tool-call-integrity.js +92 -0
  39. package/dist/runtime/boot.js +2 -12
  40. package/dist/tools/report-session/preview-report.d.ts +2 -1
  41. package/dist/tools/report-session/preview-report.js +9 -1
  42. package/dist/tools/start-report-session.js +17 -7
  43. package/dist/workspace/paths.d.ts +0 -21
  44. package/dist/workspace/paths.js +0 -37
  45. package/package.json +1 -3
  46. package/dist/agents/report-builder.d.ts +0 -34
  47. package/dist/agents/report-builder.js +0 -34
  48. package/dist/execution/report-render.d.ts +0 -39
  49. package/dist/execution/report-render.js +0 -98
  50. package/dist/execution/report-runner.d.ts +0 -111
  51. package/dist/execution/report-runner.js +0 -230
  52. package/dist/prompts/report-builder.d.ts +0 -1
  53. package/dist/prompts/report-builder.js +0 -174
  54. package/dist/tools/iterate-report.d.ts +0 -227
  55. package/dist/tools/iterate-report.js +0 -883
  56. package/dist/tools/lib/preview-lock.d.ts +0 -20
  57. package/dist/tools/lib/preview-lock.js +0 -35
  58. package/dist/tools/lib/report-preflight.d.ts +0 -39
  59. package/dist/tools/lib/report-preflight.js +0 -283
  60. package/dist/tools/report/build-report.d.ts +0 -13
  61. package/dist/tools/report/build-report.js +0 -27
  62. package/dist/tools/report/index.d.ts +0 -15
  63. package/dist/tools/report/index.js +0 -15
  64. package/dist/tools/report/mint-preview-url.d.ts +0 -26
  65. package/dist/tools/report/mint-preview-url.js +0 -47
  66. package/dist/tools/report/preview-publisher.d.ts +0 -53
  67. package/dist/tools/report/preview-publisher.js +0 -45
  68. package/dist/tools/report/preview-snapshot.d.ts +0 -34
  69. package/dist/tools/report/preview-snapshot.js +0 -98
  70. package/dist/tools/report/submit-report.d.ts +0 -26
  71. package/dist/tools/report/submit-report.js +0 -148
  72. package/dist/tools/report/version-fs.d.ts +0 -15
  73. package/dist/tools/report/version-fs.js +0 -165
@@ -8,6 +8,11 @@ import { type AgentRunUsage } from "./metrics.js";
8
8
  import type { AgentDefinition, EmitFn, LoopMessage, RunStep } from "./types.js";
9
9
  export interface AgentFinish {
10
10
  readonly reason: FinishReason | "aborted" | "max_iterations" | "denied";
11
+ /**
12
+ * The endpoint's own word for the stop, when the provider captured one —
13
+ * `reason` normalizes it (an Anthropic `refusal` becomes `content-filter`).
14
+ */
15
+ readonly rawFinishReason?: string;
11
16
  readonly cappedOut: boolean;
12
17
  readonly truncationRecoveries: number;
13
18
  /**
@@ -5,6 +5,7 @@ import { stripNulCharacters } from "../input-sanitization.js";
5
5
  import { createNoopLogger } from "../lib/console-logger.js";
6
6
  import { hintForZodIssue, repairToolInput } from "../lib/zod-issues.js";
7
7
  import { markInterruptedMessage, syntheticUserMessage } from "../memory/ai-sdk-message-storage.js";
8
+ import { stripUnansweredToolCalls } from "../memory/tool-call-integrity.js";
8
9
  import { classifyProviderError } from "../providers/errors.js";
9
10
  import { DEFAULT_PROMPT_CACHE, promptCacheProviderOptions } from "../providers/prompt-cache.js";
10
11
  import { resultStep } from "./run-step.js";
@@ -91,6 +92,28 @@ export async function runAgent(agent, initial, session, opts) {
91
92
  const logFinish = (level, reason, cappedOut) => {
92
93
  log[level]("run finished", { iterations, reason, cappedOut, truncationRecoveries, toolCalls: toolCallCount, toolErrors: toolErrorCount, usage });
93
94
  };
95
+ /**
96
+ * Settle the transcript on the way out — called at every return. A reply can
97
+ * carry a complete tool call beside ANY finish reason, because the call
98
+ * streams before the stop reason arrives, and only the dispatching branches
99
+ * (`tool-calls`, a call-carrying `stop`, and `length`) run one. A call this
100
+ * run never dispatched is stripped
101
+ * rather than executed (a filtered or aborted reply must not act) or answered
102
+ * with an invented result (the model would read a fabrication as ground
103
+ * truth). Without the strip, the unanswered call violates the wire contract —
104
+ * every tool call carries a result — and the provider boundary then refuses
105
+ * the whole transcript on every later turn of the thread. Scoped past
106
+ * `initial`: the caller's prefix is not this run's to repair.
107
+ */
108
+ const settleTranscript = () => {
109
+ const droppedCalls = stripUnansweredToolCalls(messages, initial.length);
110
+ if (droppedCalls.length === 0)
111
+ return;
112
+ log.warn("undispatched tool calls stripped at run exit", {
113
+ toolCallIds: droppedCalls.map((d) => d.toolCallId),
114
+ tools: droppedCalls.map((d) => d.toolName),
115
+ });
116
+ };
94
117
  const usageRecorder = opts.usageRecorder ?? createNoopUsageRecorder();
95
118
  /**
96
119
  * Fold one completed call into both rollups and hand it to the recorder.
@@ -135,6 +158,7 @@ export async function runAgent(agent, initial, session, opts) {
135
158
  // lands in a dispatch round — after the concurrent siblings in that same round
136
159
  // have completed and been appended. Mirrors the clean-stop terminal path.
137
160
  const stopOnDenial = async (i) => {
161
+ settleTranscript();
138
162
  await emit({ type: "iteration", source, index: i, final: true });
139
163
  recordAgentRun({ agentId: agent.id, iterations, cappedOut: false, usage });
140
164
  logFinish("warn", "denied", false);
@@ -195,6 +219,7 @@ export async function runAgent(agent, initial, session, opts) {
195
219
  return errored;
196
220
  };
197
221
  const stopOnResolved = async (i) => {
222
+ settleTranscript();
198
223
  await emit({ type: "iteration", source, index: i, final: true });
199
224
  recordAgentRun({ agentId: agent.id, iterations, cappedOut: false, usage });
200
225
  return { messages, finish: { reason: "stop", cappedOut: false, truncationRecoveries } };
@@ -266,11 +291,32 @@ export async function runAgent(agent, initial, session, opts) {
266
291
  return stopOnResolved(i);
267
292
  continue;
268
293
  }
269
- if (reply.finishReason !== "tool-calls") {
294
+ // A local model behind an OpenAI-compatible server routinely labels a
295
+ // tool-calling reply `stop`. The calls are complete and the model waits
296
+ // on their results, thus the mislabeled round dispatches like a
297
+ // `tool-calls` one. Every other terminal keeps the strip: a filtered or
298
+ // aborted reply must not act.
299
+ const dispatchesRound = reply.finishReason === "tool-calls" || (reply.finishReason === "stop" && toolCalls.length > 0);
300
+ if (!dispatchesRound) {
301
+ settleTranscript();
302
+ // The settle can remove a call-only aborted partial whole; the
303
+ // interruption marker then rides the last assistant that survived,
304
+ // matching the no-output abort contract above.
305
+ if (reply.finishReason === "aborted")
306
+ markLastLoopAssistant(messages, initial.length);
270
307
  await emit({ type: "iteration", source, index: i, final: true });
271
308
  recordAgentRun({ agentId: agent.id, iterations, cappedOut: false, usage });
272
309
  logFinish("info", reply.finishReason, false);
273
- return { messages, finish: { reason: reply.finishReason, cappedOut: false, truncationRecoveries, ...finishUsage() } };
310
+ return {
311
+ messages,
312
+ finish: {
313
+ reason: reply.finishReason,
314
+ ...(reply.rawFinishReason === undefined ? {} : { rawFinishReason: reply.rawFinishReason }),
315
+ cappedOut: false,
316
+ truncationRecoveries,
317
+ ...finishUsage(),
318
+ },
319
+ };
274
320
  }
275
321
  await emit({ type: "iteration", source, index: i, final: false });
276
322
  log.debug("iteration", { iteration: i, tools: toolCalls.map((t) => t.toolName) });
@@ -307,6 +353,7 @@ export async function runAgent(agent, initial, session, opts) {
307
353
  // exhausted its iterations, while the reason carries the abort.
308
354
  if (assistantHasContent(wrapUp.message))
309
355
  messages.push(wrapUp.message);
356
+ settleTranscript();
310
357
  markLastLoopAssistant(messages, initial.length);
311
358
  await emit({ type: "iteration", source, index: agent.maxIterations, final: true });
312
359
  recordAgentRun({ agentId: agent.id, iterations, cappedOut: true, usage });
@@ -314,6 +361,7 @@ export async function runAgent(agent, initial, session, opts) {
314
361
  return { messages, finish: { reason: "aborted", cappedOut: true, truncationRecoveries, ...finishUsage() } };
315
362
  }
316
363
  messages.push(wrapUp.message);
364
+ settleTranscript();
317
365
  await emit({ type: "iteration", source, index: agent.maxIterations, final: true });
318
366
  recordAgentRun({ agentId: agent.id, iterations, cappedOut: true, usage });
319
367
  logFinish("warn", "max_iterations", true);
@@ -2,7 +2,7 @@
2
2
  * `runToTerminal` — terminal-salvage wrapper around `runAgent`.
3
3
  *
4
4
  * Some agents communicate their result EXCLUSIVELY through a terminal tool
5
- * (`submit_plan`, `submit_report`, `submit_profile`, `submit_synthesis`, …):
5
+ * (`submit_plan`, `submit_profile`, `submit_synthesis`, …):
6
6
  * the loop's text reply is discarded and the outcome is read from a closure
7
7
  * cell the terminal tool writes. Plain `runAgent` ends a capped run on a
8
8
  * tool-LESS wrap-up turn, so an agent that burns its whole iteration budget —
@@ -2,7 +2,7 @@
2
2
  * `runToTerminal` — terminal-salvage wrapper around `runAgent`.
3
3
  *
4
4
  * Some agents communicate their result EXCLUSIVELY through a terminal tool
5
- * (`submit_plan`, `submit_report`, `submit_profile`, `submit_synthesis`, …):
5
+ * (`submit_plan`, `submit_profile`, `submit_synthesis`, …):
6
6
  * the loop's text reply is discarded and the outcome is read from a closure
7
7
  * cell the terminal tool writes. Plain `runAgent` ends a capped run on a
8
8
  * tool-LESS wrap-up turn, so an agent that burns its whole iteration budget —
@@ -39,14 +39,6 @@ export interface RunCardData {
39
39
  title: string;
40
40
  stepCount: number;
41
41
  }
42
- export interface PreviewCardData {
43
- id: string;
44
- previewId: string;
45
- version: number;
46
- title: string;
47
- previewPath: string;
48
- format: "html" | "pdf";
49
- }
50
42
  /** Build the `data-plan` payload by loading the stored plan. `ok(null)` when
51
43
  * the plan is missing or no longer parses (absence is NOT an error) — the
52
44
  * caller falls back to a chip. */
@@ -100,22 +92,3 @@ export declare function fileGroupHash(paths: string[], title: string | undefined
100
92
  * path the live tool rejected would be resurrected into a card on reload.
101
93
  */
102
94
  export declare function buildFileReferenceCardData(input: Record<string, unknown>): FileReferenceCardData | null;
103
- /**
104
- * Build the `data-report-preview` payload for an `iterate_report` tool_use. The
105
- * preview's source of truth is the filesystem under
106
- * `previews/{previewId}/`: directory layout yields `version`
107
- * (latest `vN`) + `previewPath`, and `preview-meta.json` yields title/format.
108
- * Mirrors `buildRunCardData` — the live emit knows the server-generated
109
- * `previewId`, while the reconstruct-on-read path resolves it (from the
110
- * iteration's `previewId` input, or else the analysis's latest preview). The
111
- * card is keyed by `previewId` only, so every iteration's tool_use rebuilds
112
- * the same card and the frontend groups them into one with all versions selectable.
113
- * Null when nothing renders — the caller falls back to a chip.
114
- */
115
- export declare function buildPreviewCardData(
116
- /** Absolute host root of the analysis's workspace tree (previews live inside it). */
117
- workspaceRoot: string, opts: {
118
- previewId?: string;
119
- title?: string;
120
- format?: "html" | "pdf";
121
- }): Promise<PreviewCardData | null>;
@@ -16,17 +16,12 @@
16
16
  * under the part `type` directly.
17
17
  */
18
18
  import { createHash } from "node:crypto";
19
- import { readdir, readFile, stat } from "node:fs/promises";
20
- import { join } from "node:path";
21
19
  import { okAsync } from "neverthrow";
22
20
  import { tryQuery } from "../lib/db-result.js";
23
- import { tryFs } from "../lib/fs-result.js";
24
21
  import { AnalysisPlanSchema } from "../schemas/workflow-state.js";
25
22
  import { loadPlan } from "../state/index.js";
26
23
  import { normalizeEchartSpec } from "../tools/display/normalize-echart-spec.js";
27
24
  import { validatePath } from "../tools/lib/path-validation.js";
28
- import { latestPreviewVersion, previewDir, PREVIEWS_ROOT } from "../workspace/paths.js";
29
- const PREVIEW_META_FILE = "preview-meta.json";
30
25
  function isRecord(value) {
31
26
  return typeof value === "object" && value !== null && !Array.isArray(value);
32
27
  }
@@ -176,73 +171,3 @@ export function buildFileReferenceCardData(input) {
176
171
  const id = `pres-${fileGroupHash(files.map((f) => f.path), title)}`;
177
172
  return { id, ...(title !== undefined ? { title } : {}), files };
178
173
  }
179
- /** The stored preview metadata, or `null` when the file is absent — a malformed
180
- * file is a genuine read failure (`err`), folded to `null` at the call site. */
181
- function readPreviewMeta(previewRootAbs) {
182
- const path = join(previewRootAbs, PREVIEW_META_FILE);
183
- return tryFs("cardBuilders.readPreviewMeta", async () => JSON.parse(await readFile(path, "utf8")), { path, onAbsent: () => null });
184
- }
185
- /** Directory names (not files) directly under `dir`, or `[]` when absent. */
186
- function subdirs(dir) {
187
- return tryFs("cardBuilders.subdirs", async () => (await readdir(dir, { withFileTypes: true })).filter((e) => e.isDirectory()).map((e) => e.name), {
188
- path: dir,
189
- onAbsent: () => [],
190
- });
191
- }
192
- /** The most-recently-modified previewId under an analysis, or null when none. */
193
- function latestPreviewId(previewsRootAbs) {
194
- return subdirs(previewsRootAbs).map(async (dirs) => {
195
- if (dirs.length <= 1)
196
- return dirs[0] ?? null;
197
- let best = null;
198
- for (const name of dirs) {
199
- const path = join(previewsRootAbs, name);
200
- const mtimeMs = await tryFs("cardBuilders.latestPreviewId.stat", async () => (await stat(path)).mtimeMs, { path }).unwrapOr(null); // skip an unreadable/absent entry
201
- if (mtimeMs !== null && (!best || mtimeMs > best.mtimeMs)) {
202
- best = { name, mtimeMs };
203
- }
204
- }
205
- return best?.name ?? dirs[0];
206
- });
207
- }
208
- /**
209
- * Build the `data-report-preview` payload for an `iterate_report` tool_use. The
210
- * preview's source of truth is the filesystem under
211
- * `previews/{previewId}/`: directory layout yields `version`
212
- * (latest `vN`) + `previewPath`, and `preview-meta.json` yields title/format.
213
- * Mirrors `buildRunCardData` — the live emit knows the server-generated
214
- * `previewId`, while the reconstruct-on-read path resolves it (from the
215
- * iteration's `previewId` input, or else the analysis's latest preview). The
216
- * card is keyed by `previewId` only, so every iteration's tool_use rebuilds
217
- * the same card and the frontend groups them into one with all versions selectable.
218
- * Null when nothing renders — the caller falls back to a chip.
219
- */
220
- export async function buildPreviewCardData(
221
- /** Absolute host root of the analysis's workspace tree (previews live inside it). */
222
- workspaceRoot, opts) {
223
- const previewsRootAbs = join(workspaceRoot, PREVIEWS_ROOT);
224
- const resolveId = opts.previewId ? okAsync(opts.previewId) : latestPreviewId(previewsRootAbs);
225
- return resolveId
226
- .andThen((previewId) => {
227
- if (!previewId)
228
- return okAsync(null);
229
- const previewRootAbs = join(workspaceRoot, previewDir(previewId));
230
- return subdirs(previewRootAbs).andThen((versionDirs) => {
231
- const version = latestPreviewVersion(versionDirs);
232
- if (version === 0)
233
- return okAsync(null);
234
- return readPreviewMeta(previewRootAbs).map((meta) => {
235
- const id = `prev-${createHash("sha256").update(previewId).digest("hex").slice(0, 16)}`;
236
- return {
237
- id,
238
- previewId,
239
- version,
240
- title: opts.title ?? meta?.title ?? "Report",
241
- previewPath: `v${version}/index.html`,
242
- format: opts.format ?? (meta?.format === "pdf" ? "pdf" : "html"),
243
- };
244
- });
245
- });
246
- })
247
- .unwrapOr(null);
248
- }
@@ -1,9 +1,7 @@
1
1
  import type { Pool } from "pg";
2
2
  import type { Tool } from "../tools/define-tool.js";
3
- import type { ResolveWorkspaceRoot } from "../workspace/paths.js";
4
3
  export interface ConversationDisplayBackfillDeps {
5
4
  readonly pool: Pool;
6
- readonly resolveWorkspaceRoot: ResolveWorkspaceRoot;
7
5
  /**
8
6
  * The conversation agent's composed roster, used to recover each legacy call's
9
7
  * one-line detail from its persisted input. Tools contributed through the
@@ -17,8 +15,8 @@ export interface ConversationDisplayBackfillDeps {
17
15
  * Freeze the migration renderer's output into display envelopes, once per legacy
18
16
  * turn, so the runtime read path never has to reconstruct anything.
19
17
  *
20
- * Missing mutable resources degrade to a generic, cardless display — a workspace
21
- * that has moved is normal historical absence, not a fault. Malformed stored rows
18
+ * Missing mutable resources degrade to a generic, cardless display — a plan
19
+ * that is absent is normal historical absence, not a fault. Malformed stored rows
22
20
  * and database faults remain startup-fatal: a turn that cannot be migrated must
23
21
  * not be silently skipped and then read as if it had no display.
24
22
  */
@@ -10,8 +10,8 @@ const GENUINE_USER_SQL = `m.message_envelope->'message'->>'role' = 'user'
10
10
  * Freeze the migration renderer's output into display envelopes, once per legacy
11
11
  * turn, so the runtime read path never has to reconstruct anything.
12
12
  *
13
- * Missing mutable resources degrade to a generic, cardless display — a workspace
14
- * that has moved is normal historical absence, not a fault. Malformed stored rows
13
+ * Missing mutable resources degrade to a generic, cardless display — a plan
14
+ * that is absent is normal historical absence, not a fault. Malformed stored rows
15
15
  * and database faults remain startup-fatal: a turn that cannot be migrated must
16
16
  * not be silently skipped and then read as if it had no display.
17
17
  */
@@ -64,13 +64,7 @@ export async function backfillConversationDisplayEnvelopes(deps) {
64
64
  });
65
65
  let resolveCard;
66
66
  if (head.analysis_id !== null) {
67
- try {
68
- resolveCard = createCardResolver(deps.pool, head.analysis_id, deps.resolveWorkspaceRoot(head.analysis_id));
69
- }
70
- catch {
71
- // A removed/moved workspace is normal historical absence.
72
- resolveCard = undefined;
73
- }
67
+ resolveCard = createCardResolver(deps.pool, head.analysis_id);
74
68
  }
75
69
  const cortex = await contentToCortexMessages(stored, { resolveCard, resolveDetail });
76
70
  const uiMessages = cortexMessagesToConversationUI(cortex);
@@ -15,7 +15,7 @@
15
15
  * reconstruction.
16
16
  */
17
17
  import { type DataUIPart, type UIMessage } from "ai";
18
- import type { AskPart, FileReferencePart, PlanPart, PresentationPart, PreviewPart, DataPreviewFailedPart, RunCardPart } from "../contracts/chat-parts.js";
18
+ import type { AskPart, ChildSessionStartedPart, FileReferencePart, PlanPart, PresentationPart, ReportRenderedPart, RunCardPart } from "../contracts/chat-parts.js";
19
19
  import type { ToolCallOutcome } from "../contracts/chat-events.js";
20
20
  import type { CortexMessage, CortexPart, ToolCallPart } from "../contracts/message.js";
21
21
  export declare const SUPPORTED_DISPLAY_SCHEMA_VERSION = 1;
@@ -38,8 +38,8 @@ export type ConversationUIData = {
38
38
  "run-card": Payload<RunCardPart>;
39
39
  "file-reference": Payload<FileReferencePart>;
40
40
  ask: Payload<AskPart>;
41
- "report-preview": Payload<PreviewPart>;
42
- "report-preview-failed": Payload<DataPreviewFailedPart>;
41
+ "child-session-started": Payload<ChildSessionStartedPart>;
42
+ "report-rendered": Payload<ReportRenderedPart>;
43
43
  };
44
44
  export interface ConversationDisplayMetadata {
45
45
  interrupted?: boolean;
@@ -16,7 +16,7 @@
16
16
  */
17
17
  import { validateUIMessages } from "ai";
18
18
  import { z } from "zod";
19
- import { AskPartSchema, DataPreviewFailedPartSchema, FileReferencePartSchema, PlanPartSchema, PresentationPartSchema, PreviewPartSchema, RunCardPartSchema, } from "../contracts/schemas/chat-parts.js";
19
+ import { AskPartSchema, ChildSessionStartedPartSchema, FileReferencePartSchema, PlanPartSchema, PresentationPartSchema, ReportRenderedPartSchema, RunCardPartSchema, } from "../contracts/schemas/chat-parts.js";
20
20
  import { ToolCallOutcomeSchema } from "../contracts/schemas/chat-events.js";
21
21
  export const SUPPORTED_DISPLAY_SCHEMA_VERSION = 1;
22
22
  const DisplayEnvelopeHeaderSchema = z.object({
@@ -54,8 +54,8 @@ const dataSchemas = {
54
54
  "run-card": RunCardPartSchema.omit({ type: true }).strict(),
55
55
  "file-reference": FileReferencePartSchema.omit({ type: true }).strict(),
56
56
  ask: AskPartSchema.omit({ type: true }).strict(),
57
- "report-preview": PreviewPartSchema.omit({ type: true }).strict(),
58
- "report-preview-failed": DataPreviewFailedPartSchema.omit({ type: true }).strict(),
57
+ "child-session-started": ChildSessionStartedPartSchema.omit({ type: true }).strict(),
58
+ "report-rendered": ReportRenderedPartSchema.omit({ type: true }).strict(),
59
59
  };
60
60
  export function envelopeDisplayMessages(messages) {
61
61
  if (messages.length === 0)
@@ -4,15 +4,13 @@
4
4
  * through the pre-v1 direct-upgrade window, then remove its runtime fallback
5
5
  * in a dedicated compatibility change.
6
6
  *
7
- * `show_plan` / `show_user` / `show_file` / `execute_analysis` / `iterate_report` emit
8
- * `data-plan` / `data-presentation` / `data-file-reference` / `data-run-card` /
9
- * `data-report-preview` cards
7
+ * `show_plan` / `show_user` / `show_file` / `execute_analysis` emit
8
+ * `data-plan` / `data-presentation` / `data-file-reference` / `data-run-card` cards
10
9
  * live over the chat SSE stream. On reload, `content-to-cortex` calls this
11
10
  * resolver for each AI SDK tool-call part: a recognised display tool yields its card (rebuilt via the
12
11
  * shared `card-builders`); anything else yields `null` and falls back to a
13
- * generic `tool-call` chip. Keyed by the analysis `Pool` + id (plan / run) or
14
- * the analysis's workspace root (preview, filesystem-backed) so the card can
15
- * be re-loaded.
12
+ * generic `tool-call` chip. Keyed by the analysis `Pool` + id (plan / run) so
13
+ * the card can be re-loaded.
16
14
  */
17
15
  import type { CortexPart } from "@inflexa-ai/harness/contracts/message.js";
18
16
  import type { Pool } from "pg";
@@ -26,4 +24,4 @@ export interface StoredToolCallForCard {
26
24
  /** @deprecated Used only by the legacy display backfill/fallback. */
27
25
  export type ToolCardResolver = (block: StoredToolCallForCard) => Promise<CortexPart | null>;
28
26
  /** @deprecated Use persisted conversation display envelopes for runtime reads. */
29
- export declare function createCardResolver(pool: Pool, analysisId: string, workspaceRoot: string): ToolCardResolver;
27
+ export declare function createCardResolver(pool: Pool, analysisId: string): ToolCardResolver;
@@ -4,38 +4,24 @@
4
4
  * through the pre-v1 direct-upgrade window, then remove its runtime fallback
5
5
  * in a dedicated compatibility change.
6
6
  *
7
- * `show_plan` / `show_user` / `show_file` / `execute_analysis` / `iterate_report` emit
8
- * `data-plan` / `data-presentation` / `data-file-reference` / `data-run-card` /
9
- * `data-report-preview` cards
7
+ * `show_plan` / `show_user` / `show_file` / `execute_analysis` emit
8
+ * `data-plan` / `data-presentation` / `data-file-reference` / `data-run-card` cards
10
9
  * live over the chat SSE stream. On reload, `content-to-cortex` calls this
11
10
  * resolver for each AI SDK tool-call part: a recognised display tool yields its card (rebuilt via the
12
11
  * shared `card-builders`); anything else yields `null` and falls back to a
13
- * generic `tool-call` chip. Keyed by the analysis `Pool` + id (plan / run) or
14
- * the analysis's workspace root (preview, filesystem-backed) so the card can
15
- * be re-loaded.
12
+ * generic `tool-call` chip. Keyed by the analysis `Pool` + id (plan / run) so
13
+ * the card can be re-loaded.
16
14
  */
17
15
  import { unwrapOrThrow } from "../lib/result.js";
18
16
  import { adHocPlanId, adHocRunId } from "../tools/analysis-invocation.js";
19
17
  import { validatePath } from "../tools/lib/path-validation.js";
20
- import { buildFileReferenceCardData, buildPlanCardData, buildPresentationCardData, buildPreviewCardData, buildRunCardData } from "./card-builders.js";
18
+ import { buildFileReferenceCardData, buildPlanCardData, buildPresentationCardData, buildRunCardData } from "./card-builders.js";
21
19
  /** @deprecated Use persisted conversation display envelopes for runtime reads. */
22
- export function createCardResolver(pool, analysisId, workspaceRoot) {
20
+ export function createCardResolver(pool, analysisId) {
23
21
  return async (block) => {
24
22
  if (block.type !== "tool_use")
25
23
  return null;
26
24
  const input = (block.input ?? {});
27
- // The report tool is `submit_report`; legacy transcripts carry the
28
- // former ids `iterate_report` / `iterateReport` — accept all three. The
29
- // brief-carrying field is still `report`, so the card reads identically.
30
- if (block.name === "submit_report" || block.name === "iterate_report" || block.name === "iterateReport") {
31
- const report = (input.report ?? null);
32
- const card = await buildPreviewCardData(workspaceRoot, {
33
- previewId: typeof input.previewId === "string" ? input.previewId : undefined,
34
- title: report && typeof report.title === "string" ? report.title : undefined,
35
- format: input.format === "pdf" || input.format === "html" ? input.format : undefined,
36
- });
37
- return card ? { type: "data-report-preview", ...card } : null;
38
- }
39
25
  if (block.name === "show_user") {
40
26
  // The live tool rejects a malformed/traversal echart `dataPath` and emits nothing; the
41
27
  // persisted tool_use still carries it, so the reload path MUST re-validate before building
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Transcript integrity for tool calls.
3
+ *
4
+ * The wire contract of every provider is that each tool call in an assistant
5
+ * message has a matching tool result. A transcript that violates it is refused
6
+ * WHOLE — the AI SDK throws at prompt conversion, and the Anthropic API answers
7
+ * 400 — so a single unanswered call makes a thread unable to take any further
8
+ * turn. An unanswered call is a reachable state, not a hypothetical: a reply
9
+ * can carry a complete tool call beside ANY finish reason, because the call
10
+ * streams before the stop reason arrives, and only a `tool-calls` (and, for its
11
+ * leading calls, a `length`) finish dispatches it.
12
+ *
13
+ * The repair is a STRIP, never an invented result. A call that was not
14
+ * dispatched did not happen, and a fabricated result would record an execution
15
+ * that did not occur — the model would read it as ground truth on the next
16
+ * turn. The prose of the message survives the strip; a message left with
17
+ * nothing the wire can render disappears entirely.
18
+ */
19
+ import type { ModelMessage } from "ai";
20
+ /** One tool call removed by {@link stripUnansweredToolCalls}, identified for the caller's diagnostic record. */
21
+ export interface DroppedToolCall {
22
+ readonly toolCallId: string;
23
+ readonly toolName: string;
24
+ }
25
+ /**
26
+ * Remove, in place, every tool-call part at or past `fromIndex` that has no
27
+ * matching tool result anywhere in `messages`. Returns the removed calls in
28
+ * transcript order — empty on the healthy path — so the caller can log what
29
+ * a reader of the stored thread would otherwise never learn.
30
+ *
31
+ * Results are matched by id across the whole array, not by adjacency: the loop
32
+ * appends a round's results directly after its assistant message, but a
33
+ * deferred-image user message can ride between rounds, and the stored shape is
34
+ * not this function's to assume.
35
+ *
36
+ * A message that keeps a text or file part survives the strip — refusal prose
37
+ * is the one account the reader has of a filtered reply. A message left with
38
+ * no such part (a call-only message, or reasoning beside the removed call) is
39
+ * removed whole: reasoning without the output it preceded is not replayable,
40
+ * and an empty assistant message is itself a wire violation.
41
+ *
42
+ * `fromIndex` bounds the REPAIR, not the result scan. The loop passes its
43
+ * `initial.length` so it never rewrites the caller's prefix; the assembly
44
+ * repair passes nothing and covers the whole stored window.
45
+ */
46
+ export declare function stripUnansweredToolCalls(messages: ModelMessage[], fromIndex?: number): DroppedToolCall[];
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Transcript integrity for tool calls.
3
+ *
4
+ * The wire contract of every provider is that each tool call in an assistant
5
+ * message has a matching tool result. A transcript that violates it is refused
6
+ * WHOLE — the AI SDK throws at prompt conversion, and the Anthropic API answers
7
+ * 400 — so a single unanswered call makes a thread unable to take any further
8
+ * turn. An unanswered call is a reachable state, not a hypothetical: a reply
9
+ * can carry a complete tool call beside ANY finish reason, because the call
10
+ * streams before the stop reason arrives, and only a `tool-calls` (and, for its
11
+ * leading calls, a `length`) finish dispatches it.
12
+ *
13
+ * The repair is a STRIP, never an invented result. A call that was not
14
+ * dispatched did not happen, and a fabricated result would record an execution
15
+ * that did not occur — the model would read it as ground truth on the next
16
+ * turn. The prose of the message survives the strip; a message left with
17
+ * nothing the wire can render disappears entirely.
18
+ */
19
+ /**
20
+ * Whether this tool call needs a client-supplied result. A provider-executed
21
+ * call is answered by the provider inside the same assistant message, so the
22
+ * wire contract makes no demand on the client for it — the AI SDK's own
23
+ * missing-result validation skips these, and so does the strip.
24
+ */
25
+ function needsClientResult(part) {
26
+ return part.providerExecuted !== true;
27
+ }
28
+ /**
29
+ * Remove, in place, every tool-call part at or past `fromIndex` that has no
30
+ * matching tool result anywhere in `messages`. Returns the removed calls in
31
+ * transcript order — empty on the healthy path — so the caller can log what
32
+ * a reader of the stored thread would otherwise never learn.
33
+ *
34
+ * Results are matched by id across the whole array, not by adjacency: the loop
35
+ * appends a round's results directly after its assistant message, but a
36
+ * deferred-image user message can ride between rounds, and the stored shape is
37
+ * not this function's to assume.
38
+ *
39
+ * A message that keeps a text or file part survives the strip — refusal prose
40
+ * is the one account the reader has of a filtered reply. A message left with
41
+ * no such part (a call-only message, or reasoning beside the removed call) is
42
+ * removed whole: reasoning without the output it preceded is not replayable,
43
+ * and an empty assistant message is itself a wire violation.
44
+ *
45
+ * `fromIndex` bounds the REPAIR, not the result scan. The loop passes its
46
+ * `initial.length` so it never rewrites the caller's prefix; the assembly
47
+ * repair passes nothing and covers the whole stored window.
48
+ */
49
+ export function stripUnansweredToolCalls(messages, fromIndex = 0) {
50
+ const resultIds = new Set();
51
+ for (const message of messages) {
52
+ if (message.role === "tool") {
53
+ for (const part of message.content) {
54
+ if (part.type === "tool-result")
55
+ resultIds.add(part.toolCallId);
56
+ }
57
+ }
58
+ else if (message.role === "assistant" && typeof message.content !== "string") {
59
+ for (const part of message.content) {
60
+ if (part.type === "tool-result")
61
+ resultIds.add(part.toolCallId);
62
+ }
63
+ }
64
+ }
65
+ const dropped = [];
66
+ // Backward, so a whole-message removal cannot shift an index this loop has
67
+ // yet to visit.
68
+ for (let idx = messages.length - 1; idx >= fromIndex; idx--) {
69
+ const message = messages[idx];
70
+ if (message.role !== "assistant" || typeof message.content === "string")
71
+ continue;
72
+ const removed = [];
73
+ for (const part of message.content) {
74
+ if (part.type === "tool-call" && needsClientResult(part) && !resultIds.has(part.toolCallId))
75
+ removed.push(part);
76
+ }
77
+ if (removed.length === 0)
78
+ continue;
79
+ // Prepended as a batch: the walk visits messages tail-first, but the
80
+ // caller's record reads in transcript order.
81
+ dropped.unshift(...removed.map((part) => ({ toolCallId: part.toolCallId, toolName: part.toolName })));
82
+ const removedParts = new Set(removed);
83
+ const kept = message.content.filter((part) => !removedParts.has(part));
84
+ if (kept.some((part) => part.type === "text" || part.type === "file" || part.type === "tool-call" || part.type === "tool-result")) {
85
+ messages[idx] = { ...message, content: kept };
86
+ }
87
+ else {
88
+ messages.splice(idx, 1);
89
+ }
90
+ }
91
+ return dropped;
92
+ }
@@ -38,7 +38,6 @@
38
38
  */
39
39
  import { unwrapOrThrow } from "../lib/result.js";
40
40
  import { SANDBOX_AGENT_META } from "../agents/sandbox/index.js";
41
- import { REPORT_BUILDER_AGENT_ID, REPORT_BUILDER_SKILLS } from "../agents/report-builder.js";
42
41
  import { validateAgentSkills } from "../agents/sandbox/validate-skills.js";
43
42
  import { initCortexState } from "../state/init.js";
44
43
  import { backfillConversationDisplayEnvelopes } from "../memory/conversation-display-backfill.js";
@@ -49,16 +48,8 @@ import { markDraining } from "./lifecycle.js";
49
48
  import { runShutdownSequence } from "./shutdown.js";
50
49
  const noop = () => { };
51
50
  const noopAsync = () => Promise.resolve();
52
- /**
53
- * Every agent whose declared packs must resolve before the first run. The
54
- * report-builder is not in the sandbox catalog — it is not plannable — but it
55
- * declares a pack all the same, and a missing one there surfaces as a failed
56
- * render partway through a report rather than at boot.
57
- */
58
- const SKILL_DECLARING_AGENTS = {
59
- ...SANDBOX_AGENT_META,
60
- [REPORT_BUILDER_AGENT_ID]: { id: REPORT_BUILDER_AGENT_ID, skills: REPORT_BUILDER_SKILLS },
61
- };
51
+ /** Every agent whose declared packs must resolve before the first run. */
52
+ const SKILL_DECLARING_AGENTS = SANDBOX_AGENT_META;
62
53
  export async function bootHarness(deps) {
63
54
  const { core, pool, skillsDir, logger } = deps;
64
55
  (deps.initTelemetry ?? noop)();
@@ -73,7 +64,6 @@ export async function bootHarness(deps) {
73
64
  const conversationAgent = unwrapOrThrow(runtime.agents.forThread("conversation"));
74
65
  await backfillConversationDisplayEnvelopes({
75
66
  pool,
76
- resolveWorkspaceRoot: core.conversation.resolveWorkspaceRoot,
77
67
  tools: conversationAgent.tools,
78
68
  });
79
69
  await deps.beforeLaunch?.();
@@ -28,7 +28,8 @@
28
28
  * render problem, a figure that escapes the workspace root, a write failure, and a stamp failure. The tool
29
29
  * never throws for one of them. When the page
30
30
  * lands, the tool stamps the hash of the rendered draft on the session state, thus the eyes and the record
31
- * know which draft the page shows. The filesystem speaks the
31
+ * know which draft the page shows. It then emits one durable `data-report-rendered` part, thus the
32
+ * transcript places the render and a live client learns that a fresh page exists. The filesystem speaks the
32
33
  * throw protocol, and the workspace-root seam signals an unresolvable resource the same way. Thus the
33
34
  * write runs through the `tryFs` glue, which turns a genuine fault into the ok-channel outcome and lets a
34
35
  * control-flow exception propagate.
@@ -28,12 +28,14 @@
28
28
  * render problem, a figure that escapes the workspace root, a write failure, and a stamp failure. The tool
29
29
  * never throws for one of them. When the page
30
30
  * lands, the tool stamps the hash of the rendered draft on the session state, thus the eyes and the record
31
- * know which draft the page shows. The filesystem speaks the
31
+ * know which draft the page shows. It then emits one durable `data-report-rendered` part, thus the
32
+ * transcript places the render and a live client learns that a fresh page exists. The filesystem speaks the
32
33
  * throw protocol, and the workspace-root seam signals an unresolvable resource the same way. Thus the
33
34
  * write runs through the `tryFs` glue, which turns a genuine fault into the ok-channel outcome and lets a
34
35
  * control-flow exception propagate.
35
36
  */
36
37
  import { err, ok } from "neverthrow";
38
+ import { randomUUID } from "node:crypto";
37
39
  import { copyFile, mkdir, readdir, rm, writeFile } from "node:fs/promises";
38
40
  import { extname, join } from "node:path";
39
41
  import { relative as relativePosix } from "node:path/posix";
@@ -475,6 +477,12 @@ export function createPreviewReportTool(deps) {
475
477
  logger.warn("the rendered hash did not stamp", { threadId, analysisId, detail });
476
478
  return ok({ outcome: "stamp-failed", pagePath: written.value, detail });
477
479
  }
480
+ // The part rides the rendered arm only. Each degraded arm shows no
481
+ // fresh page, thus it emits nothing.
482
+ await ctx.emit({
483
+ type: "data-report-rendered",
484
+ data: { id: randomUUID(), renderedAt: new Date().toISOString(), title: document.title },
485
+ });
478
486
  const access = await mintAccess(deps.makeSessionPages, analysisId, threadId, ctx.session.auth, logger);
479
487
  return ok({ outcome: "rendered", pagePath: written.value, ...(access !== undefined ? { access } : {}) });
480
488
  },