@inflexa-ai/harness 0.41.2 → 0.42.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/dist/agents/conversation-agent.js +3 -0
  2. package/dist/agents/report-session-agent.d.ts +2 -2
  3. package/dist/agents/report-session-agent.js +4 -0
  4. package/dist/app/chat-turn.d.ts +0 -4
  5. package/dist/app/chat-turn.js +39 -25
  6. package/dist/app/message-assembly.d.ts +2 -2
  7. package/dist/app/message-assembly.js +1 -1
  8. package/dist/contracts/chat-events.d.ts +12 -5
  9. package/dist/contracts/chat-events.js +3 -3
  10. package/dist/contracts/chat-frame.d.ts +41 -0
  11. package/dist/contracts/chat-frame.js +173 -0
  12. package/dist/contracts/chat-parts.d.ts +15 -3
  13. package/dist/contracts/chat-parts.js +2 -2
  14. package/dist/contracts/index.d.ts +6 -4
  15. package/dist/contracts/index.js +1 -0
  16. package/dist/contracts/message.d.ts +7 -5
  17. package/dist/contracts/message.js +1 -1
  18. package/dist/contracts/part-registry.d.ts +6 -6
  19. package/dist/contracts/schemas/chat-events.d.ts +2 -2
  20. package/dist/contracts/schemas/chat-events.js +2 -2
  21. package/dist/contracts/schemas/chat-parts.d.ts +34 -2
  22. package/dist/contracts/schemas/chat-parts.js +9 -3
  23. package/dist/contracts/schemas/index.d.ts +2 -2
  24. package/dist/contracts/schemas/index.js +2 -2
  25. package/dist/execution/run-event-parts.d.ts +19 -11
  26. package/dist/execution/run-event-parts.js +0 -0
  27. package/dist/execution/run-event-stream.d.ts +2 -2
  28. package/dist/index.d.ts +10 -6
  29. package/dist/index.js +4 -2
  30. package/dist/lib/page-capture.d.ts +27 -9
  31. package/dist/lib/page-capture.js +81 -33
  32. package/dist/loop/compaction.d.ts +8 -3
  33. package/dist/loop/run-agent.d.ts +11 -0
  34. package/dist/loop/run-agent.js +62 -35
  35. package/dist/loop/types.d.ts +3 -0
  36. package/dist/memory/ai-sdk-message-storage.d.ts +18 -3
  37. package/dist/memory/ai-sdk-message-storage.js +23 -1
  38. package/dist/memory/conversation-display-recorder.d.ts +7 -1
  39. package/dist/memory/conversation-display-recorder.js +59 -119
  40. package/dist/memory/conversation-display-replay.d.ts +3 -3
  41. package/dist/memory/conversation-display-replay.js +7 -21
  42. package/dist/memory/conversation-display-storage.d.ts +10 -10
  43. package/dist/memory/conversation-display-storage.js +9 -8
  44. package/dist/memory/conversation-view.d.ts +5 -4
  45. package/dist/memory/conversation-view.js +14 -18
  46. package/dist/memory/count-tokens.js +1 -0
  47. package/dist/memory/thread-store.d.ts +9 -2
  48. package/dist/memory/thread-store.js +8 -2
  49. package/dist/prompts/compaction.d.ts +2 -2
  50. package/dist/prompts/compaction.js +2 -2
  51. package/dist/prompts/report-session.d.ts +1 -1
  52. package/dist/prompts/report-session.js +40 -28
  53. package/dist/report-render/views/block-mark.d.ts +8 -0
  54. package/dist/report-render/views/block-mark.js +10 -0
  55. package/dist/report-render/views/chart-view.js +2 -1
  56. package/dist/report-render/views/prose.js +10 -6
  57. package/dist/report-render/views/values.js +5 -4
  58. package/dist/state/migrations/20260927180000_thread_title_set_by_user.d.ts +6 -0
  59. package/dist/state/migrations/20260927180000_thread_title_set_by_user.js +10 -0
  60. package/dist/state/migrations/index.js +2 -0
  61. package/dist/tools/report-authoring/authoring-tools.d.ts +16 -5
  62. package/dist/tools/report-authoring/authoring-tools.js +52 -24
  63. package/dist/tools/report-session/examine-page.d.ts +29 -14
  64. package/dist/tools/report-session/examine-page.js +40 -16
  65. package/dist/tools/report-session/list-artifacts.d.ts +23 -10
  66. package/dist/tools/report-session/list-artifacts.js +51 -31
  67. package/dist/tools/report-session/page-faults.d.ts +29 -0
  68. package/dist/tools/report-session/page-faults.js +48 -0
  69. package/dist/tools/report-session/preview-report.d.ts +9 -2
  70. package/dist/tools/report-session/preview-report.js +13 -3
  71. package/dist/tools/report-session/record-version.d.ts +5 -1
  72. package/dist/tools/report-session/record-version.js +4 -3
  73. package/package.json +1 -1
@@ -67,6 +67,12 @@ export interface RunAgentOptions {
67
67
  * `recordsAfter` reads the database outside a step.
68
68
  */
69
69
  readonly compaction?: CompactionPolicy;
70
+ /**
71
+ * The messages that open the turn of a chat thread: the user message and its context records. The loop appends
72
+ * them after the turn-start compaction, thus the conversation holds each message in the order that it was sent.
73
+ * Absent: `initial` holds the whole conversation.
74
+ */
75
+ readonly turnInput?: readonly LoopMessage[];
70
76
  /**
71
77
  * Per-turn user-approval seam threaded into every tool's `ToolContext` as
72
78
  * `ctx.ask`. A conversation tool calls it to pause for an explicit user
@@ -184,6 +190,11 @@ export interface LoopSegment {
184
190
  * prefix (system prompt, tools, tool choice) as the one before it.
185
191
  */
186
192
  export interface OpenLoop {
193
+ /**
194
+ * Compact the conversation when the last request passed the turn-start budget, and then append the input of the
195
+ * turn. The result of the run when an abort ends the compaction, or `undefined` when the run continues.
196
+ */
197
+ startTurn(input: readonly LoopMessage[]): Promise<RunAgentResult | undefined>;
187
198
  /** Run one segment: the result of the run when a reply ends it, or `"capped"` when the segment used its cap. */
188
199
  runSegment(segment: LoopSegment): Promise<SegmentResult | "capped">;
189
200
  /** End a run after its last segment used its cap. */
@@ -8,8 +8,8 @@ import { ATTR_INFLEXA_TOOL_USE_ID, passThroughSpan, stableSpan } from "../lib/ot
8
8
  import { ResultError, unwrapOrThrow } from "../lib/result.js";
9
9
  import { describeZodIssueShapes } from "../lib/zod-issue-shape.js";
10
10
  import { hintForZodIssue, repairToolInput } from "../lib/zod-issues.js";
11
- import { dropMarkerMessage, markCompactionExchange, markInterruptedMessage, summaryMarkerMessage, syntheticUserMessage, } from "../memory/ai-sdk-message-storage.js";
12
- import { conversationView, keptTurnsForDrop, viewTokens } from "../memory/conversation-view.js";
11
+ import { contextRecordOf, markCompactionExchange, markInterruptedMessage, summaryMarkerMessage, syntheticUserMessage, withRequestInputTokens, } from "../memory/ai-sdk-message-storage.js";
12
+ import { conversationView, lastRequestInputTokens } from "../memory/conversation-view.js";
13
13
  import { answerUnansweredToolCalls } from "../memory/tool-call-integrity.js";
14
14
  import { classifyProviderError, extractStatus } from "../providers/errors.js";
15
15
  import { DEFAULT_PROMPT_CACHE, withPromptCacheBreakpoint, withSystemPromptBreakpoint } from "../providers/prompt-cache.js";
@@ -42,6 +42,11 @@ export const WRAP_UP_MAX_REQUESTS = 2;
42
42
  async function runAgentLoop(agent, initial, session, opts) {
43
43
  const formatStepName = opts.formatStepName ?? DEFAULT_STEP_NAME_FORMATTER;
44
44
  const loop = openLoop(agent, initial, session, opts, agent.id);
45
+ if (opts.turnInput !== undefined) {
46
+ const ended = await loop.startTurn(opts.turnInput);
47
+ if (ended !== undefined)
48
+ return ended;
49
+ }
45
50
  const task = await loop.runSegment({ mask: opts.toolMask, maxRequests: agent.maxIterations, stepNames: formatStepName, firstIndex: 0, checksBudget: true });
46
51
  if (task !== "capped")
47
52
  return task;
@@ -100,6 +105,8 @@ export function openLoop(agent, initial, session, opts, metricAgentId) {
100
105
  let continuesTruncation = false;
101
106
  let compactionStopped = false;
102
107
  let compactions = 0;
108
+ // The first request after a summary shows if the summary brought the conversation under the budget.
109
+ let summarizedLast = false;
103
110
  // Bound from the same `source` the emitted events carry: one derivation feeding
104
111
  // both sinks, so a record and an event cannot disagree about who produced them.
105
112
  // `callPath` rides as an array rather than a joined string — the queryable form;
@@ -224,7 +231,7 @@ export function openLoop(agent, initial, session, opts, metricAgentId) {
224
231
  if (!endsOnRequestRefusal) {
225
232
  const reply = await resultStep(callStep)(stepName, chat);
226
233
  accountForChatCall(reply, { ...accounting, stepName });
227
- return reply;
234
+ return withRequestAnchor(reply);
228
235
  }
229
236
  // The step gives the `Result` as its value, thus a refusal ends the segment and no step throws for it.
230
237
  const result = await callStep(stepName, async () => await chat());
@@ -233,7 +240,7 @@ export function openLoop(agent, initial, session, opts, metricAgentId) {
233
240
  return { refusal };
234
241
  const reply = unwrapOrThrow(result);
235
242
  accountForChatCall(reply, { ...accounting, stepName });
236
- return reply;
243
+ return withRequestAnchor(reply);
237
244
  };
238
245
  // The user said no. A subsequent model call would only let the agent argue
239
246
  // with the decision, or spend a call acknowledging it; the denial tool result
@@ -327,13 +334,13 @@ export function openLoop(agent, initial, session, opts, metricAgentId) {
327
334
  return { messages, finish: { reason: "error", cappedOut: false, truncationRecoveries, ...finishUsage() }, refusal };
328
335
  };
329
336
  /** The result of the run when an abort ends the exchange, or `undefined` when the run continues. */
330
- const compact = async (active, tokensBefore, index) => {
337
+ const compact = async (active, tokensBefore, index, trigger) => {
331
338
  const id = randomUUID();
332
339
  const stepNamespace = `compaction-${compactions++}`;
333
340
  const startedAt = performance.now();
334
- const emitPart = (data) => emit({ type: "data-compaction", source, data });
341
+ const emitPart = (data) => emit({ type: "data-compaction", source, data: { ...data, trigger } });
335
342
  await emitPart({ id, status: "running", tokensBefore });
336
- const { onRound: _onRound, compaction: _compaction, ...conversationOptions } = opts;
343
+ const { onRound: _onRound, compaction: _compaction, turnInput: _turnInput, ...conversationOptions } = opts;
337
344
  let settled = false;
338
345
  try {
339
346
  const exchange = await continueAgent(agent, viewOf(), {
@@ -357,36 +364,25 @@ export function openLoop(agent, initial, session, opts, metricAgentId) {
357
364
  return { messages, finish: { reason: "aborted", cappedOut: false, truncationRecoveries, ...finishUsage() } };
358
365
  }
359
366
  const summary = summaryOf(exchange);
360
- const keptTurns = summary === undefined ? keptTurnsForDrop(messages, viewOptions, active.budget) : 0;
361
- const markerOf = (tokensAfter, durationMs) => summary === undefined
362
- ? dropMarkerMessage({ kind: "drop", id, tokensBefore, tokensAfter, durationMs, keptTurns })
363
- : summaryMarkerMessage(summary, { kind: "summary", id, tokensBefore, tokensAfter, durationMs });
367
+ if (summary === undefined) {
368
+ settled = true;
369
+ const durationMs = Math.round(performance.now() - startedAt);
370
+ await emitPart({ id, status: "failed", tokensBefore, durationMs });
371
+ const refusal = exchange.refusal === undefined ? {} : { status: exchange.refusal.status, providerError: exchange.refusal.message };
372
+ log.error("compaction gave no summary, thus the run compacts no more", { compactionId: id, trigger, tokensBefore, durationMs, ...refusal });
373
+ compactionStopped = true;
374
+ return undefined;
375
+ }
376
+ const markerOf = (durationMs) => summaryMarkerMessage(summary, { kind: "summary", id, tokensBefore, durationMs, trigger });
364
377
  // The figures of a marker do not change the view, thus a draft marker gives the view of the real one.
365
- const view = conversationView([...messages, markerOf(0, 0)], viewOptions).messages;
366
- const records = await active.recordsAfter(view);
367
- const tokensAfter = viewTokens([...view, ...records]);
378
+ const records = await active.recordsAfter(conversationView([...messages, markerOf(0)], viewOptions).messages);
368
379
  const durationMs = Math.round(performance.now() - startedAt);
369
- messages.push(markerOf(tokensAfter, durationMs), ...records);
380
+ messages.push(markerOf(durationMs), ...records);
370
381
  await giveRound();
371
382
  settled = true;
372
- await emitPart({ id, status: summary === undefined ? "failed" : "done", tokensBefore, tokensAfter, durationMs });
373
- const fields = { compactionId: id, tokensBefore, tokensAfter, durationMs };
374
- if (summary === undefined) {
375
- const refusal = exchange.refusal === undefined ? {} : { status: exchange.refusal.status, providerError: exchange.refusal.message };
376
- log.warn("compaction gave no summary, thus the oldest turns left the view", { ...fields, keptTurns, ...refusal });
377
- }
378
- else {
379
- log.info("conversation compacted", fields);
380
- }
381
- if (summary !== undefined && tokensAfter > active.budget) {
382
- log.warn("the compacted view still exceeds the budget, thus the run compacts no more", {
383
- compactionId: id,
384
- tokensAfter,
385
- budget: active.budget,
386
- });
387
- }
388
- if (summary === undefined || tokensAfter > active.budget)
389
- compactionStopped = true;
383
+ await emitPart({ id, status: "done", tokensBefore, durationMs });
384
+ log.info("conversation compacted", { compactionId: id, trigger, tokensBefore, durationMs });
385
+ summarizedLast = true;
390
386
  return undefined;
391
387
  }
392
388
  finally {
@@ -394,11 +390,36 @@ export function openLoop(agent, initial, session, opts, metricAgentId) {
394
390
  await emitPart({ id, status: "failed", tokensBefore, durationMs: Math.round(performance.now() - startedAt) });
395
391
  }
396
392
  };
393
+ const startTurn = async (input) => {
394
+ const budget = policy?.turnStartBudget;
395
+ const tokens = lastRequestInputTokens(messages);
396
+ if (policy === undefined || budget === undefined || tokens === undefined || tokens <= budget) {
397
+ messages.push(...input);
398
+ return undefined;
399
+ }
400
+ const ended = await compact(policy, tokens, 0, "turn-start");
401
+ if (ended !== undefined)
402
+ return ended;
403
+ // The records after a summary give the current context, thus a record of the input would only repeat one.
404
+ messages.push(...(summarizedLast ? input.filter((message) => contextRecordOf(message) === undefined) : input));
405
+ return undefined;
406
+ };
397
407
  const compactOverBudget = async (index) => {
398
408
  if (policy === undefined || continuesTruncation || compactionStopped)
399
409
  return undefined;
400
- const tokensBefore = viewTokens(viewOf());
401
- return tokensBefore > policy.budget ? compact(policy, tokensBefore, index) : undefined;
410
+ const tokens = lastRequestInputTokens(messages);
411
+ if (tokens === undefined)
412
+ return undefined;
413
+ const afterSummary = summarizedLast;
414
+ summarizedLast = false;
415
+ if (tokens <= policy.budget)
416
+ return undefined;
417
+ if (afterSummary) {
418
+ log.warn("the first request after the summary still exceeds the budget, thus the run compacts no more", { tokens, budget: policy.budget });
419
+ compactionStopped = true;
420
+ return undefined;
421
+ }
422
+ return compact(policy, tokens, index, "mid-turn");
402
423
  };
403
424
  const runSegment = async (segment) => {
404
425
  const names = segment.stepNames;
@@ -558,6 +579,7 @@ export function openLoop(agent, initial, session, opts, metricAgentId) {
558
579
  }
559
580
  };
560
581
  return {
582
+ startTurn: (input) => giveRoundBeforeThrow(() => startTurn(input)),
561
583
  runSegment: (segment) => giveRoundBeforeThrow(() => runSegment(segment)),
562
584
  endCapped: () => giveRoundBeforeThrow(endCapped),
563
585
  };
@@ -577,6 +599,11 @@ function summaryOf(exchange) {
577
599
  const text = finalText(exchange.messages).trim();
578
600
  return text.length > 0 ? text : undefined;
579
601
  }
602
+ /** The reply whose message holds the input tokens of its request, which the budget checks read. */
603
+ function withRequestAnchor(reply) {
604
+ const tokens = reply.usage?.inputTokens;
605
+ return tokens === undefined ? reply : { ...reply, message: withRequestInputTokens(reply.message, tokens) };
606
+ }
580
607
  /**
581
608
  * Called at the fold point, before any run-ending branch, so an aborted or
582
609
  * dead run is still accounted. Token counters grow inside the call's step
@@ -11,6 +11,7 @@ import type { ModelMessage } from "ai";
11
11
  import type { ToolCallDetail, ToolOutcome } from "../contracts/chat-events.js";
12
12
  import type { ChatStreamEvent } from "../providers/types.js";
13
13
  import type { Tool } from "../tools/define-tool.js";
14
+ import type { CompactionRules } from "./compaction.js";
14
15
  /**
15
16
  * A loop message. The harness's working message array is AI SDK-shaped
16
17
  * `ModelMessage`.
@@ -28,6 +29,8 @@ export interface AgentDefinition {
28
29
  readonly tools: readonly Tool[];
29
30
  /** Runaway guard: at the cap the loop runs up to two masked wrap-up requests, until it gets a text answer. */
30
31
  readonly maxIterations: number;
32
+ /** The compaction rules of an agent that a chat thread runs. Absent: the conversation never compacts. */
33
+ readonly compaction?: CompactionRules;
31
34
  }
32
35
  /**
33
36
  * The durability seam. `passthroughStep` (`(_, fn) => fn()`) in the chat
@@ -1,4 +1,4 @@
1
- import { type ModelMessage, type UserContent } from "ai";
1
+ import { type AssistantModelMessage, type ModelMessage, type UserContent } from "ai";
2
2
  import { z } from "zod";
3
3
  export declare const SUPPORTED_AI_SDK_MAJOR = 7;
4
4
  export declare const StoredMessageEnvelopeSchema: z.ZodObject<{
@@ -110,21 +110,36 @@ export declare function isSyntheticRecordMessage(message: ModelMessage): boolean
110
110
  export declare const COMPACTION_EXCHANGE_KEY = "compactionExchange";
111
111
  /** The {@link HARNESS_PROVIDER_NAMESPACE} key that holds the figures of a compaction marker, which the view rule reads. */
112
112
  export declare const COMPACTION_MARKER_KEY = "compactionMarker";
113
+ declare const COMPACTION_TRIGGERS: readonly ["turn-start", "mid-turn"];
114
+ /** Where a compaction started: before the user message of a turn, or before a later request. */
115
+ export type CompactionTrigger = (typeof COMPACTION_TRIGGERS)[number];
113
116
  declare const CompactionMarkerSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
114
117
  id: z.ZodString;
115
118
  tokensBefore: z.ZodNumber;
116
- tokensAfter: z.ZodNumber;
117
119
  durationMs: z.ZodNumber;
120
+ trigger: z.ZodOptional<z.ZodEnum<{
121
+ "turn-start": "turn-start";
122
+ "mid-turn": "mid-turn";
123
+ }>>;
118
124
  kind: z.ZodLiteral<"summary">;
119
125
  }, z.core.$strip>, z.ZodObject<{
120
126
  keptTurns: z.ZodNumber;
121
127
  id: z.ZodString;
122
128
  tokensBefore: z.ZodNumber;
123
- tokensAfter: z.ZodNumber;
124
129
  durationMs: z.ZodNumber;
130
+ trigger: z.ZodOptional<z.ZodEnum<{
131
+ "turn-start": "turn-start";
132
+ "mid-turn": "mid-turn";
133
+ }>>;
125
134
  kind: z.ZodLiteral<"drop">;
126
135
  }, z.core.$strip>], "kind">;
127
136
  export type CompactionMarker = z.infer<typeof CompactionMarkerSchema>;
137
+ /** The {@link HARNESS_PROVIDER_NAMESPACE} key that holds the input tokens that the provider reported for the request of an assistant message. */
138
+ export declare const REQUEST_INPUT_TOKENS_KEY = "requestInputTokens";
139
+ /** A copy of `message` whose harness namespace holds the input tokens of its request. The mark keeps each other namespace. */
140
+ export declare function withRequestInputTokens(message: AssistantModelMessage, tokens: number): AssistantModelMessage;
141
+ /** The input tokens of the request of an assistant message, or `undefined` when the provider reported none. */
142
+ export declare function requestInputTokensOf(message: ModelMessage): number | undefined;
128
143
  /** A copy of `message` whose harness namespace, which no provider reads, holds the compaction id. */
129
144
  export declare function markCompactionExchange(message: ModelMessage, id: string): ModelMessage;
130
145
  export declare function compactionExchangeOf(message: ModelMessage): string | undefined;
@@ -162,11 +162,33 @@ export function isSyntheticRecordMessage(message) {
162
162
  export const COMPACTION_EXCHANGE_KEY = "compactionExchange";
163
163
  /** The {@link HARNESS_PROVIDER_NAMESPACE} key that holds the figures of a compaction marker, which the view rule reads. */
164
164
  export const COMPACTION_MARKER_KEY = "compactionMarker";
165
- const compactionFigures = { id: z.string(), tokensBefore: z.number(), tokensAfter: z.number(), durationMs: z.number() };
165
+ const COMPACTION_TRIGGERS = ["turn-start", "mid-turn"];
166
+ // A row from before the trigger has none.
167
+ const compactionFigures = { id: z.string(), tokensBefore: z.number(), durationMs: z.number(), trigger: z.enum(COMPACTION_TRIGGERS).optional() };
166
168
  const CompactionMarkerSchema = z.discriminatedUnion("kind", [
167
169
  z.object({ kind: z.literal("summary"), ...compactionFigures }),
168
170
  z.object({ kind: z.literal("drop"), ...compactionFigures, keptTurns: z.number() }),
169
171
  ]);
172
+ /** The {@link HARNESS_PROVIDER_NAMESPACE} key that holds the input tokens that the provider reported for the request of an assistant message. */
173
+ export const REQUEST_INPUT_TOKENS_KEY = "requestInputTokens";
174
+ /** A copy of `message` whose harness namespace holds the input tokens of its request. The mark keeps each other namespace. */
175
+ export function withRequestInputTokens(message, tokens) {
176
+ const existingOptions = message.providerOptions ?? {};
177
+ return {
178
+ ...message,
179
+ providerOptions: {
180
+ ...existingOptions,
181
+ [HARNESS_PROVIDER_NAMESPACE]: { ...existingOptions[HARNESS_PROVIDER_NAMESPACE], [REQUEST_INPUT_TOKENS_KEY]: tokens },
182
+ },
183
+ };
184
+ }
185
+ /** The input tokens of the request of an assistant message, or `undefined` when the provider reported none. */
186
+ export function requestInputTokensOf(message) {
187
+ if (message.role !== "assistant")
188
+ return undefined;
189
+ const tokens = message.providerOptions?.[HARNESS_PROVIDER_NAMESPACE]?.[REQUEST_INPUT_TOKENS_KEY];
190
+ return typeof tokens === "number" && Number.isFinite(tokens) ? tokens : undefined;
191
+ }
170
192
  /** A copy of `message` whose harness namespace, which no provider reads, holds the compaction id. */
171
193
  export function markCompactionExchange(message, id) {
172
194
  const existingOptions = message.providerOptions ?? {};
@@ -15,9 +15,15 @@ export interface ConversationDisplayRecorder {
15
15
  }
16
16
  export interface ConversationDisplayRecorderOptions {
17
17
  readonly userText: string;
18
- readonly topLevelCallPath: readonly string[];
18
+ /** The root agent of the turn. Its call path is `[agentId]`, and an event with a longer path is from a sub-agent. */
19
+ readonly agentId: string;
19
20
  readonly sink: EmitFn;
20
21
  readonly userMessageId?: string;
21
22
  readonly assistantMessageId?: string;
22
23
  }
24
+ /**
25
+ * Record the display of a turn through the same translation path as a live surface: each event becomes a
26
+ * frame (`toChatFrame`), and the frames build the messages of the round (`applyChatFrame`). Only a part that
27
+ * the conversation keeps enters the record.
28
+ */
23
29
  export declare function createConversationDisplayRecorder(options: ConversationDisplayRecorderOptions): ConversationDisplayRecorder;
@@ -1,33 +1,36 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { applyChatFrame, checkChatPart, isRootFrame, toChatFrame } from "../contracts/chat-frame.js";
2
3
  import { PART_REGISTRY } from "../contracts/part-registry.js";
3
- import { CortexChatPartSchema } from "../contracts/schemas/chat-parts.js";
4
4
  import { compactionExchangeOf, compactionMarkerOf } from "./ai-sdk-message-storage.js";
5
5
  import { conversationDisplayPart } from "./conversation-display-storage.js";
6
6
  function jsonCopy(value) {
7
7
  return JSON.parse(JSON.stringify(value));
8
8
  }
9
- function isSubAgent(event, topLevelCallPath) {
10
- if (!("source" in event) || event.source === undefined)
11
- return false;
12
- const path = event.source.callPath;
13
- return path.length > topLevelCallPath.length && topLevelCallPath.every((entry, index) => path[index] === entry);
14
- }
15
9
  function durableConversationType(type) {
16
10
  if (!Object.hasOwn(PART_REGISTRY, type))
17
11
  return false;
18
12
  const descriptor = PART_REGISTRY[type];
19
13
  return descriptor.emitter === "conversation" && descriptor.consumer === "conversation" && !descriptor.transient;
20
14
  }
21
- function dataPart(event) {
22
- const parsed = CortexChatPartSchema.safeParse({ type: event.type, ...event.data });
23
- if (!parsed.success)
24
- throw new Error(`Invalid emitted conversation part ${event.type}: ${parsed.error.message}`);
25
- const { type, ...data } = parsed.data;
26
- return {
27
- type,
28
- id: "id" in data && typeof data.id === "string" ? data.id : undefined,
29
- data: jsonCopy(data),
30
- };
15
+ /** The checked part of a data frame, copied at receipt, because the emitter keeps its own reference to the payload. */
16
+ function recordedPart(frame) {
17
+ const checked = checkChatPart(frame);
18
+ if (!checked.ok)
19
+ throw new Error(`Invalid emitted conversation part ${frame.type}: ${checked.error}`);
20
+ return jsonCopy(checked.frame);
21
+ }
22
+ /**
23
+ * The stored form of the parts of one round. A round that closes ends each question that it asked, thus a
24
+ * pending ask is stored `aborted`, and a call with no outcome is stored `incomplete` ({@link conversationDisplayPart}).
25
+ */
26
+ function displayParts(parts) {
27
+ return parts.flatMap((part) => {
28
+ if (part.type === "text")
29
+ return part.text.length === 0 ? [] : [{ type: "text", text: part.text, state: "done" }];
30
+ if (part.type === "data-ask" && part.status === "pending")
31
+ return [conversationDisplayPart({ ...part, status: "aborted" })];
32
+ return [conversationDisplayPart(part)];
33
+ });
31
34
  }
32
35
  /** The text of the assistant messages of a round. The text of an exchange is a summary, and no reply. */
33
36
  function assistantText(messages) {
@@ -48,148 +51,85 @@ function compactionDivider(marker) {
48
51
  id: marker.id,
49
52
  status: marker.kind === "summary" ? "done" : "failed",
50
53
  tokensBefore: marker.tokensBefore,
51
- tokensAfter: marker.tokensAfter,
52
54
  durationMs: marker.durationMs,
55
+ ...(marker.trigger === undefined ? {} : { trigger: marker.trigger }),
53
56
  });
54
57
  return { id: marker.id, role: "system", parts: [part] };
55
58
  }
59
+ /**
60
+ * Record the display of a turn through the same translation path as a live surface: each event becomes a
61
+ * frame (`toChatFrame`), and the frames build the messages of the round (`applyChatFrame`). Only a part that
62
+ * the conversation keeps enters the record.
63
+ */
56
64
  export function createConversationDisplayRecorder(options) {
57
65
  // One id for each message of the turn, thus the rounds of a turn replay as one assistant message.
58
66
  const userMessageId = options.userMessageId ?? randomUUID();
59
67
  let assistantMessageId = options.assistantMessageId ?? randomUUID();
60
- const assistantParts = [];
61
- // Where a reconciling part lives, keyed by `type:id`. Type-qualified because a
62
- // stable id is only unique within its own family — a plan and a run card may
63
- // legitimately share one.
64
- const reconcileIndexes = new Map();
68
+ const rootSource = { agentId: options.agentId, callPath: [options.agentId] };
69
+ // The messages of the open round. A later update of a part lands in the next round, and the replay replaces the earlier copy.
70
+ let round = [];
65
71
  let finished = false;
66
- /**
67
- * Place a part, replacing the one it reconciles with when it has a stable id.
68
- * A tool call reaching its `finished` state and an `ask` reaching its resolved
69
- * status are the same operation: latest-wins, in the position the part first
70
- * took, so a later part never jumps the ordering the user saw.
71
- */
72
- function upsert(part, reconciling) {
73
- if (!reconciling || part.id === undefined) {
74
- assistantParts.push(part);
75
- return;
76
- }
77
- const key = `${part.type}:${part.id}`;
78
- const index = reconcileIndexes.get(key);
79
- if (index !== undefined) {
80
- assistantParts[index] = part;
81
- return;
82
- }
83
- reconcileIndexes.set(key, assistantParts.length);
84
- assistantParts.push(part);
72
+ function apply(frame) {
73
+ round = applyChatFrame(round, frame, assistantMessageId).messages;
74
+ }
75
+ function roundParts() {
76
+ return round.find((message) => message.id === assistantMessageId)?.parts ?? [];
85
77
  }
86
- /**
87
- * Place or update a call's recorded state.
88
- *
89
- * A call is recorded `incomplete` the moment it is dispatched and overwritten with
90
- * its real outcome when it finishes. The record is therefore honest at every
91
- * instant, and a turn that ends mid-call needs no closing pass — the calls that
92
- * never finished are already saying so.
93
- */
94
- function recordToolCall(toolCallId, toolName, outcome, detail) {
95
- upsert(conversationDisplayPart({
96
- type: "tool-call",
97
- toolCallId,
98
- toolName,
99
- outcome,
100
- ...(detail === undefined ? {} : { detail }),
101
- }), true);
78
+ function hasText() {
79
+ return roundParts().some((part) => part.type === "text" && part.text.length > 0);
102
80
  }
103
81
  function appendText(text) {
104
- if (text.length === 0)
82
+ if (text.length > 0)
83
+ apply({ type: "text-delta", text, source: rootSource });
84
+ }
85
+ function record(event) {
86
+ const frame = toChatFrame(event, rootSource);
87
+ if (frame === null || !isRootFrame(frame))
105
88
  return;
106
- const previous = assistantParts.at(-1);
107
- if (previous?.type === "text") {
108
- previous.text += text;
89
+ if (frame.type.startsWith("data-")) {
90
+ // Each chat event type is named without the `data-` prefix, thus a `data-` frame is a part frame.
91
+ if (durableConversationType(frame.type))
92
+ apply(recordedPart(frame));
109
93
  return;
110
94
  }
111
- assistantParts.push({ type: "text", text, state: "streaming" });
112
- }
113
- function recordData(event) {
114
- if (!durableConversationType(event.type))
115
- return;
116
- upsert(dataPart(event), PART_REGISTRY[event.type].reconciling);
95
+ apply(frame);
117
96
  }
118
97
  const emit = (event) => {
119
- if (finished)
120
- return options.sink(event);
121
- if (!isSubAgent(event, options.topLevelCallPath)) {
122
- switch (event.type) {
123
- case "text-delta":
124
- appendText(event.text);
125
- break;
126
- // The detail the loop computed at dispatch is recorded as shown. It is
127
- // NOT re-derived on reload: doing that would need the tool's schema and
128
- // the raw input, which is the tool-name coupling the display projection
129
- // exists to remove.
130
- case "tool-started":
131
- recordToolCall(event.toolUseId, event.name, "incomplete", event.detail);
132
- break;
133
- case "tool-finished":
134
- recordToolCall(event.toolUseId, event.name, event.outcome, event.detail);
135
- break;
136
- case "done":
137
- case "iteration":
138
- break;
139
- default:
140
- recordData(event);
141
- break;
142
- }
143
- }
98
+ if (!finished)
99
+ record(event);
144
100
  return options.sink(event);
145
101
  };
146
- function closeParts() {
147
- // Calls need no closing pass: one that never reached `tool-finished` still holds
148
- // the `incomplete` it was recorded with at dispatch, which is the whole of what
149
- // the harness observed. An approval does need one — `pending` is a question, and
150
- // a turn that ends without answering it aborted it.
151
- for (const part of assistantParts) {
152
- if (part.type === "data-ask" && part.data.status === "pending")
153
- part.data.status = "aborted";
154
- if (part.type === "text")
155
- part.state = "done";
156
- }
157
- }
158
102
  function takeOpening() {
159
103
  return [{ id: userMessageId, role: "user", parts: [{ type: "text", text: options.userText, state: "done" }] }];
160
104
  }
161
105
  function takeRound(messages) {
162
- if (!assistantParts.some((part) => part.type === "text"))
106
+ if (!hasText())
163
107
  appendText(assistantText(messages));
164
- // Only a round that a throw ends can hold a pending approval.
165
- closeParts();
166
- const round = assistantParts.length === 0 ? [] : [{ id: assistantMessageId, role: "assistant", parts: jsonCopy(assistantParts) }];
167
- // A later update of a part lands in the next round, and the replay replaces the earlier copy.
168
- assistantParts.length = 0;
169
- reconcileIndexes.clear();
108
+ const parts = displayParts(roundParts());
109
+ const taken = parts.length === 0 ? [] : [{ id: assistantMessageId, role: "assistant", parts: jsonCopy(parts) }];
110
+ round = [];
170
111
  const marker = messages.map(compactionMarkerOf).find((found) => found !== undefined);
171
112
  if (marker !== undefined) {
172
- round.push(compactionDivider(marker));
113
+ taken.push(compactionDivider(marker));
173
114
  // The divider splits the turn into two assistant messages of the replay, and two messages must not share an id.
174
115
  assistantMessageId = randomUUID();
175
116
  }
176
- return round;
117
+ return taken;
177
118
  }
178
119
  function finish(finishOptions) {
179
120
  if (!finished) {
180
121
  finished = true;
181
- if (!assistantParts.some((part) => part.type === "text") && finishOptions?.fallbackText) {
122
+ if (!hasText() && finishOptions?.fallbackText)
182
123
  appendText(finishOptions.fallbackText);
183
- }
184
- closeParts();
185
124
  }
186
125
  const messages = takeOpening();
187
- if (assistantParts.length > 0) {
126
+ const parts = displayParts(roundParts());
127
+ if (parts.length > 0) {
188
128
  messages.push({
189
129
  id: assistantMessageId,
190
130
  role: "assistant",
191
131
  ...(finishOptions?.interrupted ? { metadata: { interrupted: true } } : {}),
192
- parts: jsonCopy(assistantParts),
132
+ parts: jsonCopy(parts),
193
133
  });
194
134
  }
195
135
  return messages;
@@ -1,11 +1,11 @@
1
1
  /**
2
- * The runtime transcript read: stored display projections → `CortexMessage[]`. It walks the rows in
2
+ * The runtime transcript read: stored display projections → `ChatMessage[]`. It walks the rows in
3
3
  * `seq` order, merges the rounds of a turn into one assistant message, and folds the turn record onto
4
4
  * that message. It consults no tool name, no card builder, and no workspace or database.
5
5
  *
6
6
  * A row with no envelope predates the display projection and is skipped. A display rebuilt from the
7
7
  * model transcript would let each later change of a tool or a card rewrite history.
8
8
  */
9
- import type { CortexMessage } from "../contracts/message.js";
9
+ import type { ChatMessage } from "../contracts/message.js";
10
10
  import type { StoredMessage } from "./thread-history.js";
11
- export declare function storedMessagesToCortex(messages: readonly StoredMessage[]): CortexMessage[];
11
+ export declare function storedMessagesToChat(messages: readonly StoredMessage[]): ChatMessage[];
@@ -1,32 +1,18 @@
1
1
  /**
2
- * The runtime transcript read: stored display projections → `CortexMessage[]`. It walks the rows in
2
+ * The runtime transcript read: stored display projections → `ChatMessage[]`. It walks the rows in
3
3
  * `seq` order, merges the rounds of a turn into one assistant message, and folds the turn record onto
4
4
  * that message. It consults no tool name, no card builder, and no workspace or database.
5
5
  *
6
6
  * A row with no envelope predates the display projection and is skipped. A display rebuilt from the
7
7
  * model transcript would let each later change of a tool or a card rewrite history.
8
8
  */
9
- import { isReconciling } from "../contracts/part-registry.js";
9
+ import { upsertPart } from "../contracts/chat-frame.js";
10
10
  import { isSyntheticUserMessage } from "./ai-sdk-message-storage.js";
11
- import { conversationUIToCortexMessages } from "./conversation-display-storage.js";
12
- /** The key that a part reconciles on, or `undefined` for a part that each round adds again. */
13
- function reconcileKey(part) {
14
- if (part.type === "tool-call")
15
- return `tool-call:${part.toolCallId}`;
16
- if (part.type === "text" || !isReconciling(part.type))
17
- return undefined;
18
- return "id" in part && typeof part.id === "string" ? `${part.type}:${part.id}` : undefined;
19
- }
11
+ import { conversationUIToChatMessages } from "./conversation-display-storage.js";
20
12
  /** Add the parts of a later round to the assistant message of its turn. */
21
13
  function mergeRound(target, round) {
22
- for (const part of round.parts) {
23
- const key = reconcileKey(part);
24
- const earlier = key === undefined ? -1 : target.parts.findIndex((candidate) => reconcileKey(candidate) === key);
25
- if (earlier >= 0)
26
- target.parts[earlier] = part;
27
- else
28
- target.parts.push(part);
29
- }
14
+ for (const part of round.parts)
15
+ target.parts = upsertPart(target.parts, part);
30
16
  }
31
17
  function foldTurnRecord(message, record) {
32
18
  if (message === undefined || record === undefined)
@@ -38,7 +24,7 @@ function foldTurnRecord(message, record) {
38
24
  if (record.status === "aborted")
39
25
  message.interrupted = true;
40
26
  }
41
- export function storedMessagesToCortex(messages) {
27
+ export function storedMessagesToChat(messages) {
42
28
  const out = [];
43
29
  // The last message of the group being walked, as `out` holds it.
44
30
  let groupLast;
@@ -62,7 +48,7 @@ export function storedMessagesToCortex(messages) {
62
48
  const createdAt = row.createdAt === undefined ? {} : { createdAt: row.createdAt.toISOString() };
63
49
  const author = row.author === undefined ? {} : { author: row.author };
64
50
  groupLast = undefined;
65
- for (const message of conversationUIToCortexMessages(row.displayEnvelope.messages)) {
51
+ for (const message of conversationUIToChatMessages(row.displayEnvelope.messages)) {
66
52
  const stamped = { ...message, ...createdAt, ...(message.role === "user" ? author : {}) };
67
53
  const previous = out.at(-1);
68
54
  // The merged message keeps the creation time of its first round.