@inflexa-ai/harness 0.41.2 → 0.42.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.
- package/dist/agents/conversation-agent.js +3 -0
- package/dist/agents/report-session-agent.d.ts +2 -2
- package/dist/agents/report-session-agent.js +4 -0
- package/dist/app/chat-turn.d.ts +0 -4
- package/dist/app/chat-turn.js +39 -25
- package/dist/app/message-assembly.d.ts +2 -2
- package/dist/app/message-assembly.js +1 -1
- package/dist/contracts/chat-events.d.ts +12 -5
- package/dist/contracts/chat-events.js +3 -3
- package/dist/contracts/chat-frame.d.ts +40 -0
- package/dist/contracts/chat-frame.js +166 -0
- package/dist/contracts/chat-parts.d.ts +15 -3
- package/dist/contracts/chat-parts.js +2 -2
- package/dist/contracts/index.d.ts +6 -4
- package/dist/contracts/index.js +1 -0
- package/dist/contracts/message.d.ts +7 -5
- package/dist/contracts/message.js +1 -1
- package/dist/contracts/part-registry.d.ts +6 -6
- package/dist/contracts/schemas/chat-events.d.ts +2 -2
- package/dist/contracts/schemas/chat-events.js +2 -2
- package/dist/contracts/schemas/chat-parts.d.ts +34 -2
- package/dist/contracts/schemas/chat-parts.js +9 -3
- package/dist/contracts/schemas/index.d.ts +2 -2
- package/dist/contracts/schemas/index.js +2 -2
- package/dist/execution/run-event-parts.d.ts +4 -4
- package/dist/execution/run-event-parts.js +0 -0
- package/dist/execution/run-event-stream.d.ts +2 -2
- package/dist/index.d.ts +9 -6
- package/dist/index.js +3 -2
- package/dist/lib/page-capture.d.ts +27 -9
- package/dist/lib/page-capture.js +81 -33
- package/dist/loop/compaction.d.ts +8 -3
- package/dist/loop/run-agent.d.ts +11 -0
- package/dist/loop/run-agent.js +62 -35
- package/dist/loop/types.d.ts +3 -0
- package/dist/memory/ai-sdk-message-storage.d.ts +18 -3
- package/dist/memory/ai-sdk-message-storage.js +23 -1
- package/dist/memory/conversation-display-recorder.d.ts +7 -1
- package/dist/memory/conversation-display-recorder.js +59 -119
- package/dist/memory/conversation-display-replay.d.ts +3 -3
- package/dist/memory/conversation-display-replay.js +7 -21
- package/dist/memory/conversation-display-storage.d.ts +10 -10
- package/dist/memory/conversation-display-storage.js +9 -8
- package/dist/memory/conversation-view.d.ts +5 -4
- package/dist/memory/conversation-view.js +14 -18
- package/dist/memory/count-tokens.js +1 -0
- package/dist/memory/thread-store.d.ts +9 -2
- package/dist/memory/thread-store.js +8 -2
- package/dist/prompts/compaction.d.ts +2 -2
- package/dist/prompts/compaction.js +2 -2
- package/dist/prompts/report-session.d.ts +1 -1
- package/dist/prompts/report-session.js +40 -28
- package/dist/report-render/views/block-mark.d.ts +8 -0
- package/dist/report-render/views/block-mark.js +10 -0
- package/dist/report-render/views/chart-view.js +2 -1
- package/dist/report-render/views/prose.js +10 -6
- package/dist/report-render/views/values.js +5 -4
- package/dist/state/migrations/20260927180000_thread_title_set_by_user.d.ts +6 -0
- package/dist/state/migrations/20260927180000_thread_title_set_by_user.js +10 -0
- package/dist/state/migrations/index.js +2 -0
- package/dist/tools/report-authoring/authoring-tools.d.ts +16 -5
- package/dist/tools/report-authoring/authoring-tools.js +52 -24
- package/dist/tools/report-session/examine-page.d.ts +29 -14
- package/dist/tools/report-session/examine-page.js +40 -16
- package/dist/tools/report-session/list-artifacts.d.ts +23 -10
- package/dist/tools/report-session/list-artifacts.js +51 -31
- package/dist/tools/report-session/page-faults.d.ts +29 -0
- package/dist/tools/report-session/page-faults.js +48 -0
- package/dist/tools/report-session/preview-report.d.ts +9 -2
- package/dist/tools/report-session/preview-report.js +13 -3
- package/dist/tools/report-session/record-version.d.ts +5 -1
- package/dist/tools/report-session/record-version.js +4 -3
- package/package.json +1 -1
package/dist/loop/run-agent.js
CHANGED
|
@@ -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 {
|
|
12
|
-
import { conversationView,
|
|
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
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
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
|
|
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(
|
|
380
|
+
messages.push(markerOf(durationMs), ...records);
|
|
370
381
|
await giveRound();
|
|
371
382
|
settled = true;
|
|
372
|
-
await emitPart({ id, status:
|
|
373
|
-
|
|
374
|
-
|
|
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
|
|
401
|
-
|
|
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
|
package/dist/loop/types.d.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
return
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
61
|
-
//
|
|
62
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (!
|
|
106
|
+
if (!hasText())
|
|
163
107
|
appendText(assistantText(messages));
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
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
|
|
117
|
+
return taken;
|
|
177
118
|
}
|
|
178
119
|
function finish(finishOptions) {
|
|
179
120
|
if (!finished) {
|
|
180
121
|
finished = true;
|
|
181
|
-
if (!
|
|
122
|
+
if (!hasText() && finishOptions?.fallbackText)
|
|
182
123
|
appendText(finishOptions.fallbackText);
|
|
183
|
-
}
|
|
184
|
-
closeParts();
|
|
185
124
|
}
|
|
186
125
|
const messages = takeOpening();
|
|
187
|
-
|
|
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(
|
|
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 → `
|
|
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 {
|
|
9
|
+
import type { ChatMessage } from "../contracts/message.js";
|
|
10
10
|
import type { StoredMessage } from "./thread-history.js";
|
|
11
|
-
export declare function
|
|
11
|
+
export declare function storedMessagesToChat(messages: readonly StoredMessage[]): ChatMessage[];
|
|
@@ -1,32 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The runtime transcript read: stored display projections → `
|
|
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 {
|
|
9
|
+
import { upsertPart } from "../contracts/chat-frame.js";
|
|
10
10
|
import { isSyntheticUserMessage } from "./ai-sdk-message-storage.js";
|
|
11
|
-
import {
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|