@inflexa-ai/harness 0.39.2 → 0.40.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/README.md +2 -3
- package/dist/agents/conversation-agent.d.ts +3 -0
- package/dist/agents/conversation-agent.js +13 -2
- package/dist/agents/report-session-agent.d.ts +3 -0
- package/dist/agents/report-session-agent.js +2 -0
- package/dist/agents/sandbox/cheminformatics-agent.js +0 -3
- package/dist/agents/sandbox/shared.d.ts +11 -2
- package/dist/agents/sandbox/shared.js +4 -0
- package/dist/app/chat-turn.d.ts +75 -12
- package/dist/app/chat-turn.js +149 -21
- package/dist/app/message-assembly.d.ts +16 -52
- package/dist/app/message-assembly.js +41 -68
- package/dist/app/run-activity.d.ts +4 -6
- package/dist/app/run-activity.js +7 -27
- package/dist/app/spawn-report-session.d.ts +6 -11
- package/dist/app/spawn-report-session.js +5 -6
- package/dist/app/synthesize-run.d.ts +3 -0
- package/dist/app/synthesize-run.js +1 -0
- package/dist/billing/usage-recorder.d.ts +3 -4
- package/dist/billing/usage-recorder.js +3 -4
- package/dist/contracts/chat-parts.d.ts +11 -1
- package/dist/contracts/chat-parts.js +1 -0
- package/dist/contracts/index.d.ts +1 -1
- package/dist/contracts/part-registry.js +1 -0
- package/dist/contracts/report-blocks.d.ts +8 -8
- package/dist/contracts/schemas/chat-parts.d.ts +32 -9
- package/dist/contracts/schemas/chat-parts.js +10 -0
- package/dist/execution/artifact-metadata.d.ts +26 -33
- package/dist/execution/artifact-metadata.js +44 -106
- package/dist/execution/post-step-pipeline.d.ts +23 -16
- package/dist/execution/post-step-pipeline.js +19 -21
- package/dist/execution/run-synthesis.d.ts +3 -0
- package/dist/execution/run-synthesis.js +11 -1
- package/dist/execution/step-summary.d.ts +21 -30
- package/dist/execution/step-summary.js +30 -57
- package/dist/index.d.ts +9 -8
- package/dist/index.js +15 -34
- package/dist/lib/zod-issues.js +2 -4
- package/dist/loop/compaction.d.ts +20 -0
- package/dist/loop/compaction.js +2 -0
- package/dist/loop/continue-agent.d.ts +40 -0
- package/dist/loop/continue-agent.js +38 -0
- package/dist/loop/metrics.d.ts +30 -22
- package/dist/loop/metrics.js +51 -22
- package/dist/loop/run-agent.d.ts +111 -13
- package/dist/loop/run-agent.js +467 -220
- package/dist/loop/run-to-terminal.d.ts +16 -17
- package/dist/loop/run-to-terminal.js +24 -35
- package/dist/loop/tool-mask.d.ts +23 -0
- package/dist/loop/tool-mask.js +46 -0
- package/dist/loop/tool-output.d.ts +48 -0
- package/dist/loop/tool-output.js +79 -0
- package/dist/loop/types.d.ts +6 -3
- package/dist/memory/ai-sdk-message-storage.d.ts +49 -2
- package/dist/memory/ai-sdk-message-storage.js +78 -2
- package/dist/memory/conversation-display-recorder.d.ts +6 -0
- package/dist/memory/conversation-display-recorder.js +64 -22
- package/dist/memory/conversation-display-replay.d.ts +5 -14
- package/dist/memory/conversation-display-replay.js +66 -32
- package/dist/memory/conversation-display-storage.d.ts +2 -1
- package/dist/memory/conversation-display-storage.js +2 -1
- package/dist/memory/conversation-view.d.ts +53 -0
- package/dist/memory/conversation-view.js +130 -0
- package/dist/memory/count-tokens.d.ts +5 -7
- package/dist/memory/count-tokens.js +7 -9
- package/dist/memory/thread-history.d.ts +74 -102
- package/dist/memory/thread-history.js +181 -246
- package/dist/memory/thread-store.d.ts +3 -3
- package/dist/memory/thread-store.js +7 -2
- package/dist/memory/tool-call-integrity.d.ts +15 -21
- package/dist/memory/tool-call-integrity.js +31 -38
- package/dist/memory/working-memory.d.ts +2 -5
- package/dist/memory/working-memory.js +2 -5
- package/dist/prompts/SOUL.d.ts +2 -2
- package/dist/prompts/SOUL.js +2 -7
- package/dist/prompts/analogical-reasoner.d.ts +1 -1
- package/dist/prompts/analogical-reasoner.js +16 -32
- package/dist/prompts/briefing.js +1 -1
- package/dist/prompts/compaction.d.ts +4 -0
- package/dist/prompts/compaction.js +24 -0
- package/dist/prompts/conversation.d.ts +1 -1
- package/dist/prompts/conversation.js +26 -35
- package/dist/prompts/execute-analysis/step-summary.js +7 -5
- package/dist/prompts/literature-reviewer.d.ts +1 -1
- package/dist/prompts/literature-reviewer.js +29 -34
- package/dist/prompts/planner.js +15 -29
- package/dist/prompts/report-session.d.ts +1 -1
- package/dist/prompts/report-session.js +2 -33
- package/dist/prompts/sandbox/bulk-transcriptomics-agent.d.ts +1 -1
- package/dist/prompts/sandbox/bulk-transcriptomics-agent.js +1 -2
- package/dist/prompts/sandbox/cheminformatics-agent.d.ts +1 -1
- package/dist/prompts/sandbox/cheminformatics-agent.js +10 -29
- package/dist/prompts/sandbox/chromatin-agent.d.ts +1 -1
- package/dist/prompts/sandbox/chromatin-agent.js +1 -2
- package/dist/prompts/sandbox/data-profiler.js +6 -3
- package/dist/prompts/sandbox/dna-methylation-agent.d.ts +1 -1
- package/dist/prompts/sandbox/dna-methylation-agent.js +2 -7
- package/dist/prompts/sandbox/drug-repurposing-agent.d.ts +1 -1
- package/dist/prompts/sandbox/drug-repurposing-agent.js +20 -24
- package/dist/prompts/sandbox/enrichment-agent.d.ts +1 -1
- package/dist/prompts/sandbox/enrichment-agent.js +3 -2
- package/dist/prompts/sandbox/genomic-variant-agent.d.ts +1 -1
- package/dist/prompts/sandbox/genomic-variant-agent.js +0 -2
- package/dist/prompts/sandbox/immune-profiling-agent.d.ts +1 -1
- package/dist/prompts/sandbox/immune-profiling-agent.js +7 -14
- package/dist/prompts/sandbox/metabolomics-agent.d.ts +1 -1
- package/dist/prompts/sandbox/metabolomics-agent.js +0 -2
- package/dist/prompts/sandbox/microbiome-agent.d.ts +1 -1
- package/dist/prompts/sandbox/microbiome-agent.js +14 -21
- package/dist/prompts/sandbox/multi-omics-integration-agent.d.ts +1 -1
- package/dist/prompts/sandbox/multi-omics-integration-agent.js +9 -19
- package/dist/prompts/sandbox/multimodal-sc-agent.d.ts +1 -1
- package/dist/prompts/sandbox/multimodal-sc-agent.js +4 -5
- package/dist/prompts/sandbox/network-agent.d.ts +1 -1
- package/dist/prompts/sandbox/network-agent.js +7 -8
- package/dist/prompts/sandbox/pkpd-clinical-response-agent.d.ts +1 -1
- package/dist/prompts/sandbox/pkpd-clinical-response-agent.js +19 -19
- package/dist/prompts/sandbox/proteomics-agent.d.ts +1 -1
- package/dist/prompts/sandbox/proteomics-agent.js +3 -4
- package/dist/prompts/sandbox/scientific-executor.d.ts +1 -1
- package/dist/prompts/sandbox/scientific-executor.js +7 -10
- package/dist/prompts/sandbox/single-cell-agent.d.ts +1 -1
- package/dist/prompts/sandbox/single-cell-agent.js +2 -4
- package/dist/prompts/sandbox/spatial-omics-agent.d.ts +1 -1
- package/dist/prompts/sandbox/spatial-omics-agent.js +3 -5
- package/dist/prompts/sandbox/statistical-modeling-agent.d.ts +1 -1
- package/dist/prompts/sandbox/statistical-modeling-agent.js +1 -1
- package/dist/prompts/sandbox/translational-safety-agent.d.ts +1 -1
- package/dist/prompts/sandbox/translational-safety-agent.js +10 -19
- package/dist/prompts/sandbox-standards.d.ts +7 -7
- package/dist/prompts/sandbox-standards.js +74 -76
- package/dist/prompts/synthesis-agent.d.ts +1 -1
- package/dist/prompts/synthesis-agent.js +16 -42
- package/dist/providers/ai-sdk.d.ts +39 -6
- package/dist/providers/ai-sdk.js +117 -18
- package/dist/providers/errors.d.ts +2 -0
- package/dist/providers/errors.js +5 -0
- package/dist/providers/prompt-cache.d.ts +43 -41
- package/dist/providers/prompt-cache.js +47 -41
- package/dist/providers/reasoning.d.ts +10 -21
- package/dist/providers/reasoning.js +10 -21
- package/dist/providers/types.d.ts +21 -5
- package/dist/report-model/authoring-grammar.d.ts +2 -2
- package/dist/runtime/assemble.d.ts +9 -7
- package/dist/runtime/assemble.js +7 -3
- package/dist/sandbox/create-sandbox.d.ts +2 -2
- package/dist/sandbox/submit-exec.d.ts +1 -1
- package/dist/schemas/artifact-manifest.d.ts +4 -4
- package/dist/state/init.js +39 -4
- package/dist/state/purge-analysis.js +4 -0
- package/dist/state/tool-outputs.d.ts +4 -0
- package/dist/state/tool-outputs.js +30 -0
- package/dist/tasks/data-profile.d.ts +3 -0
- package/dist/tasks/data-profile.js +2 -0
- package/dist/tools/ad-hoc-router.d.ts +8 -0
- package/dist/tools/ad-hoc-router.js +21 -2
- package/dist/tools/display/show-user.d.ts +1 -1
- package/dist/tools/execute-analysis.d.ts +3 -0
- package/dist/tools/execute-analysis.js +9 -1
- package/dist/tools/read-tool-output.d.ts +7 -0
- package/dist/tools/read-tool-output.js +130 -0
- package/dist/tools/research/generate-analogy-report.d.ts +10 -26
- package/dist/tools/research/generate-analogy-report.js +76 -163
- package/dist/tools/research/generate-plan.d.ts +3 -0
- package/dist/tools/research/generate-plan.js +4 -1
- package/dist/tools/research/literature-reviewer.d.ts +3 -0
- package/dist/tools/research/literature-reviewer.js +3 -0
- package/dist/tools/sandbox/report-blocker.d.ts +18 -5
- package/dist/tools/sandbox/report-blocker.js +54 -6
- package/dist/tools/sandbox/submit-file-metadata.d.ts +30 -0
- package/dist/tools/sandbox/submit-file-metadata.js +67 -0
- package/dist/tools/start-report-session.d.ts +1 -3
- package/dist/tools/start-report-session.js +0 -2
- package/dist/tools/workspace/execute-command.js +7 -4
- package/dist/tools/workspace/result-bounds.d.ts +4 -4
- package/dist/tools/workspace/result-bounds.js +6 -5
- package/dist/tools/workspace/workspace-search.d.ts +1 -1
- package/dist/workflows/execute-analysis.d.ts +3 -0
- package/dist/workflows/execute-analysis.js +1 -0
- package/dist/workflows/sandbox-step.d.ts +30 -9
- package/dist/workflows/sandbox-step.js +37 -8
- package/package.json +9 -9
- package/dist/providers/anthropic.d.ts +0 -25
- package/dist/providers/anthropic.js +0 -28
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { PART_REGISTRY } from "../contracts/part-registry.js";
|
|
3
3
|
import { CortexChatPartSchema } from "../contracts/schemas/chat-parts.js";
|
|
4
|
+
import { compactionExchangeOf, compactionMarkerOf } from "./ai-sdk-message-storage.js";
|
|
4
5
|
import { conversationDisplayPart } from "./conversation-display-storage.js";
|
|
5
6
|
function jsonCopy(value) {
|
|
6
7
|
return JSON.parse(JSON.stringify(value));
|
|
@@ -28,7 +29,34 @@ function dataPart(event) {
|
|
|
28
29
|
data: jsonCopy(data),
|
|
29
30
|
};
|
|
30
31
|
}
|
|
32
|
+
/** The text of the assistant messages of a round. The text of an exchange is a summary, and no reply. */
|
|
33
|
+
function assistantText(messages) {
|
|
34
|
+
return messages
|
|
35
|
+
.filter((message) => message.role === "assistant" && compactionExchangeOf(message) === undefined)
|
|
36
|
+
.map((message) => typeof message.content === "string"
|
|
37
|
+
? message.content
|
|
38
|
+
: message.content
|
|
39
|
+
.filter((part) => part.type === "text")
|
|
40
|
+
.map((part) => part.text)
|
|
41
|
+
.join(""))
|
|
42
|
+
.join("");
|
|
43
|
+
}
|
|
44
|
+
/** The divider of a compaction marker: a `system` message with the part at the final status of the compaction. */
|
|
45
|
+
function compactionDivider(marker) {
|
|
46
|
+
const part = conversationDisplayPart({
|
|
47
|
+
type: "data-compaction",
|
|
48
|
+
id: marker.id,
|
|
49
|
+
status: marker.kind === "summary" ? "done" : "failed",
|
|
50
|
+
tokensBefore: marker.tokensBefore,
|
|
51
|
+
tokensAfter: marker.tokensAfter,
|
|
52
|
+
durationMs: marker.durationMs,
|
|
53
|
+
});
|
|
54
|
+
return { id: marker.id, role: "system", parts: [part] };
|
|
55
|
+
}
|
|
31
56
|
export function createConversationDisplayRecorder(options) {
|
|
57
|
+
// One id for each message of the turn, thus the rounds of a turn replay as one assistant message.
|
|
58
|
+
const userMessageId = options.userMessageId ?? randomUUID();
|
|
59
|
+
let assistantMessageId = options.assistantMessageId ?? randomUUID();
|
|
32
60
|
const assistantParts = [];
|
|
33
61
|
// Where a reconciling part lives, keyed by `type:id`. Type-qualified because a
|
|
34
62
|
// stable id is only unique within its own family — a plan and a run card may
|
|
@@ -115,36 +143,50 @@ export function createConversationDisplayRecorder(options) {
|
|
|
115
143
|
}
|
|
116
144
|
return options.sink(event);
|
|
117
145
|
};
|
|
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
|
+
function takeOpening() {
|
|
159
|
+
return [{ id: userMessageId, role: "user", parts: [{ type: "text", text: options.userText, state: "done" }] }];
|
|
160
|
+
}
|
|
161
|
+
function takeRound(messages) {
|
|
162
|
+
if (!assistantParts.some((part) => part.type === "text"))
|
|
163
|
+
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();
|
|
170
|
+
const marker = messages.map(compactionMarkerOf).find((found) => found !== undefined);
|
|
171
|
+
if (marker !== undefined) {
|
|
172
|
+
round.push(compactionDivider(marker));
|
|
173
|
+
// The divider splits the turn into two assistant messages of the replay, and two messages must not share an id.
|
|
174
|
+
assistantMessageId = randomUUID();
|
|
175
|
+
}
|
|
176
|
+
return round;
|
|
177
|
+
}
|
|
118
178
|
function finish(finishOptions) {
|
|
119
179
|
if (!finished) {
|
|
120
180
|
finished = true;
|
|
121
181
|
if (!assistantParts.some((part) => part.type === "text") && finishOptions?.fallbackText) {
|
|
122
182
|
appendText(finishOptions.fallbackText);
|
|
123
183
|
}
|
|
124
|
-
|
|
125
|
-
// the `incomplete` it was recorded with at dispatch, which is the whole of what
|
|
126
|
-
// the harness observed. An approval does need one — `pending` is a question, and
|
|
127
|
-
// a turn that ends without answering it aborted it.
|
|
128
|
-
for (const part of assistantParts) {
|
|
129
|
-
if (part.type === "data-ask" && part.data.status === "pending") {
|
|
130
|
-
part.data.status = "aborted";
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
for (const part of assistantParts) {
|
|
134
|
-
if (part.type === "text")
|
|
135
|
-
part.state = "done";
|
|
136
|
-
}
|
|
184
|
+
closeParts();
|
|
137
185
|
}
|
|
138
|
-
const messages =
|
|
139
|
-
{
|
|
140
|
-
id: options.userMessageId ?? randomUUID(),
|
|
141
|
-
role: "user",
|
|
142
|
-
parts: [{ type: "text", text: options.userText, state: "done" }],
|
|
143
|
-
},
|
|
144
|
-
];
|
|
186
|
+
const messages = takeOpening();
|
|
145
187
|
if (assistantParts.length > 0) {
|
|
146
188
|
messages.push({
|
|
147
|
-
id:
|
|
189
|
+
id: assistantMessageId,
|
|
148
190
|
role: "assistant",
|
|
149
191
|
...(finishOptions?.interrupted ? { metadata: { interrupted: true } } : {}),
|
|
150
192
|
parts: jsonCopy(assistantParts),
|
|
@@ -152,5 +194,5 @@ export function createConversationDisplayRecorder(options) {
|
|
|
152
194
|
}
|
|
153
195
|
return messages;
|
|
154
196
|
}
|
|
155
|
-
return { emit, finish };
|
|
197
|
+
return { emit, takeOpening, takeRound, finish };
|
|
156
198
|
}
|
|
@@ -1,19 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The runtime transcript read: stored display projections → `CortexMessage[]`.
|
|
2
|
+
* The runtime transcript read: stored display projections → `CortexMessage[]`. It walks the rows in
|
|
3
|
+
* `seq` order, merges the rounds of a turn into one assistant message, and folds the turn record onto
|
|
4
|
+
* that message. It consults no tool name, no card builder, and no workspace or database.
|
|
3
5
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* therefore a concatenation: walk the rows, emit each envelope's messages in
|
|
7
|
-
* order, done. No turn grouping, no tool-name recognition, no card rebuilding,
|
|
8
|
-
* no filesystem or database lookup — nothing that could render a reloaded
|
|
9
|
-
* conversation differently from the one the user was shown.
|
|
10
|
-
*
|
|
11
|
-
* A row with no envelope predates the display projection and is skipped — the
|
|
12
|
-
* only handling there is or will be. Reconstructing one from the model
|
|
13
|
-
* transcript instead is what the deleted migration renderer did, and rebuilding
|
|
14
|
-
* a display from a transcript that never carried one makes every later change to
|
|
15
|
-
* a tool or a card silently rewrite history. Skipping keeps the gap observable
|
|
16
|
-
* and the read total in what it consults: the stored projection, nothing else.
|
|
6
|
+
* A row with no envelope predates the display projection and is skipped. A display rebuilt from the
|
|
7
|
+
* model transcript would let each later change of a tool or a card rewrite history.
|
|
17
8
|
*/
|
|
18
9
|
import type { CortexMessage } from "../contracts/message.js";
|
|
19
10
|
import type { StoredMessage } from "./thread-history.js";
|
|
@@ -1,27 +1,56 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The runtime transcript read: stored display projections → `CortexMessage[]`.
|
|
2
|
+
* The runtime transcript read: stored display projections → `CortexMessage[]`. It walks the rows in
|
|
3
|
+
* `seq` order, merges the rounds of a turn into one assistant message, and folds the turn record onto
|
|
4
|
+
* that message. It consults no tool name, no card builder, and no workspace or database.
|
|
3
5
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* therefore a concatenation: walk the rows, emit each envelope's messages in
|
|
7
|
-
* order, done. No turn grouping, no tool-name recognition, no card rebuilding,
|
|
8
|
-
* no filesystem or database lookup — nothing that could render a reloaded
|
|
9
|
-
* conversation differently from the one the user was shown.
|
|
10
|
-
*
|
|
11
|
-
* A row with no envelope predates the display projection and is skipped — the
|
|
12
|
-
* only handling there is or will be. Reconstructing one from the model
|
|
13
|
-
* transcript instead is what the deleted migration renderer did, and rebuilding
|
|
14
|
-
* a display from a transcript that never carried one makes every later change to
|
|
15
|
-
* a tool or a card silently rewrite history. Skipping keeps the gap observable
|
|
16
|
-
* and the read total in what it consults: the stored projection, nothing else.
|
|
6
|
+
* A row with no envelope predates the display projection and is skipped. A display rebuilt from the
|
|
7
|
+
* model transcript would let each later change of a tool or a card rewrite history.
|
|
17
8
|
*/
|
|
9
|
+
import { isReconciling } from "../contracts/part-registry.js";
|
|
10
|
+
import { isSyntheticUserMessage } from "./ai-sdk-message-storage.js";
|
|
18
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
|
+
}
|
|
20
|
+
/** Add the parts of a later round to the assistant message of its turn. */
|
|
21
|
+
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
|
+
}
|
|
30
|
+
}
|
|
31
|
+
function foldTurnRecord(message, record) {
|
|
32
|
+
if (message === undefined || record === undefined)
|
|
33
|
+
return;
|
|
34
|
+
if (record.usage)
|
|
35
|
+
message.usage = record.usage;
|
|
36
|
+
if (record.durationMs !== undefined)
|
|
37
|
+
message.durationMs = record.durationMs;
|
|
38
|
+
if (record.status === "aborted")
|
|
39
|
+
message.interrupted = true;
|
|
40
|
+
}
|
|
19
41
|
export function storedMessagesToCortex(messages) {
|
|
20
42
|
const out = [];
|
|
21
|
-
// The
|
|
22
|
-
|
|
23
|
-
|
|
43
|
+
// The last message of the group being walked, as `out` holds it.
|
|
44
|
+
let groupLast;
|
|
45
|
+
// The record of the turn being walked, and the last assistant message of that turn.
|
|
46
|
+
let turnRecord;
|
|
47
|
+
let turnAssistant;
|
|
24
48
|
for (const row of messages) {
|
|
49
|
+
if (row.message.role === "user" && !isSyntheticUserMessage(row.message)) {
|
|
50
|
+
foldTurnRecord(turnAssistant, turnRecord);
|
|
51
|
+
turnRecord = row.turn;
|
|
52
|
+
turnAssistant = undefined;
|
|
53
|
+
}
|
|
25
54
|
if (row.displayEnvelope) {
|
|
26
55
|
// The author and the creation time are facts about the ROW, the same as
|
|
27
56
|
// the rollup below, and the projection holds neither. Both ride the row
|
|
@@ -32,29 +61,34 @@ export function storedMessagesToCortex(messages) {
|
|
|
32
61
|
// does not survive the JSON crossing typed, hence the ISO string here.
|
|
33
62
|
const createdAt = row.createdAt === undefined ? {} : { createdAt: row.createdAt.toISOString() };
|
|
34
63
|
const author = row.author === undefined ? {} : { author: row.author };
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
...createdAt,
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
64
|
+
groupLast = undefined;
|
|
65
|
+
for (const message of conversationUIToCortexMessages(row.displayEnvelope.messages)) {
|
|
66
|
+
const stamped = { ...message, ...createdAt, ...(message.role === "user" ? author : {}) };
|
|
67
|
+
const previous = out.at(-1);
|
|
68
|
+
// The merged message keeps the creation time of its first round.
|
|
69
|
+
if (stamped.role === "assistant" && previous?.role === "assistant" && previous.id === stamped.id)
|
|
70
|
+
mergeRound(previous, stamped);
|
|
71
|
+
else
|
|
72
|
+
out.push(stamped);
|
|
73
|
+
groupLast = out.at(-1);
|
|
74
|
+
if (groupLast?.role === "assistant")
|
|
75
|
+
turnAssistant = groupLast;
|
|
76
|
+
}
|
|
41
77
|
}
|
|
42
|
-
// The reported rollup of
|
|
78
|
+
// The reported rollup of an older turn and its duration both ride the model row that
|
|
43
79
|
// ENDED the turn, and not the display projection. Each one is a fact about what the
|
|
44
80
|
// turn cost, and not about what the turn showed. A write in both places would let
|
|
45
81
|
// the two disagree. Thus the replay folds them onto the assistant reply that a
|
|
46
82
|
// reader ties to the figures. The duration reads against `undefined`, and never
|
|
47
83
|
// against falsiness: a measured zero is a figure, and an absent value alone means
|
|
48
84
|
// that nobody measured the turn.
|
|
49
|
-
if (
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
if (row.durationMs !== undefined)
|
|
55
|
-
last.durationMs = row.durationMs;
|
|
56
|
-
}
|
|
85
|
+
if (groupLast?.role === "assistant" && (row.usage || row.durationMs !== undefined)) {
|
|
86
|
+
if (row.usage)
|
|
87
|
+
groupLast.usage = row.usage;
|
|
88
|
+
if (row.durationMs !== undefined)
|
|
89
|
+
groupLast.durationMs = row.durationMs;
|
|
57
90
|
}
|
|
58
91
|
}
|
|
92
|
+
foldTurnRecord(turnAssistant, turnRecord);
|
|
59
93
|
return out;
|
|
60
94
|
}
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* reconstruction.
|
|
16
16
|
*/
|
|
17
17
|
import { type DataUIPart, type UIMessage } from "ai";
|
|
18
|
-
import type { AskPart, ChildSessionStartedPart, FileReferencePart, PlanPart, PresentationPart, ReportRenderedPart, RunCardPart } from "../contracts/chat-parts.js";
|
|
18
|
+
import type { AskPart, ChildSessionStartedPart, CompactionPart, 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
|
import type { Logger } from "../lib/logger.js";
|
|
@@ -41,6 +41,7 @@ export type ConversationUIData = {
|
|
|
41
41
|
ask: Payload<AskPart>;
|
|
42
42
|
"child-session-started": Payload<ChildSessionStartedPart>;
|
|
43
43
|
"report-rendered": Payload<ReportRenderedPart>;
|
|
44
|
+
compaction: Payload<CompactionPart>;
|
|
44
45
|
};
|
|
45
46
|
export interface ConversationDisplayMetadata {
|
|
46
47
|
interrupted?: boolean;
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import { validateUIMessages } from "ai";
|
|
18
18
|
import { z } from "zod";
|
|
19
|
-
import { AskPartSchema, ChildSessionStartedPartSchema, FileReferencePartSchema, PlanPartSchema, PresentationPartSchema, ReportRenderedPartSchema, RunCardPartSchema, } from "../contracts/schemas/chat-parts.js";
|
|
19
|
+
import { AskPartSchema, ChildSessionStartedPartSchema, CompactionPartSchema, 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({
|
|
@@ -68,6 +68,7 @@ const dataSchemas = {
|
|
|
68
68
|
ask: AskPartSchema.omit({ type: true }),
|
|
69
69
|
"child-session-started": ChildSessionStartedPartSchema.omit({ type: true }),
|
|
70
70
|
"report-rendered": ReportRenderedPartSchema.omit({ type: true }),
|
|
71
|
+
compaction: CompactionPartSchema.omit({ type: true }),
|
|
71
72
|
};
|
|
72
73
|
export function envelopeDisplayMessages(messages) {
|
|
73
74
|
if (messages.length === 0)
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The view of a conversation, which the loop sends and `loadRecent` gives: the head, then the latest
|
|
3
|
+
* summary marker, then the body. An exchange message never joins it, and a drop marker keeps the last
|
|
4
|
+
* turns of the body without their reasoning. The loop and the reader call this one function, thus the
|
|
5
|
+
* next turn reads back the prefix that the last request sent.
|
|
6
|
+
*/
|
|
7
|
+
import type { ModelMessage } from "ai";
|
|
8
|
+
/**
|
|
9
|
+
* A turn starts on a `user` message that a human actually sent. Two kinds of
|
|
10
|
+
* message carry the `user` role without opening a turn, and both are excluded
|
|
11
|
+
* here:
|
|
12
|
+
*
|
|
13
|
+
* - a tool result, which in AI SDK terms is a `tool`-role message, so a mid-turn
|
|
14
|
+
* tool continuation never matches in the first place;
|
|
15
|
+
* - a message the LOOP or the HOST synthesized (`syntheticUserMessage`) — the
|
|
16
|
+
* truncated-reply nudge the loop inserts mid-turn, and the record an embedder
|
|
17
|
+
* appends between turns for work that happened outside the conversation, such as
|
|
18
|
+
* an analysis run's outcome. Both carry the `user` role for the wire format and
|
|
19
|
+
* neither is user input. Reading one as a boundary would split one turn into
|
|
20
|
+
* two: a drop would keep half a turn, and `retractLastTurn` would cut
|
|
21
|
+
* its tail in the middle of a turn rather than at its head.
|
|
22
|
+
*
|
|
23
|
+
* A host-appended record therefore belongs to the turn preceding it, so a tail
|
|
24
|
+
* retraction that removes that turn removes the record with it. That is the
|
|
25
|
+
* accepted consequence of the exclusion, not a defect — the alternative, letting
|
|
26
|
+
* it open a turn, is the exact failure the marker exists to prevent.
|
|
27
|
+
*
|
|
28
|
+
* `GENUINE_USER_START_SQL` in `thread-history.ts` is the twin of this predicate over stored
|
|
29
|
+
* envelopes; the two are built from the same constants so they cannot drift.
|
|
30
|
+
*/
|
|
31
|
+
export declare function isGenuineUserStart(message: ModelMessage): boolean;
|
|
32
|
+
/**
|
|
33
|
+
* Group rows (oldest-first) into turns at genuine-user-start boundaries.
|
|
34
|
+
* Generic over the row shape so the view rule and the display read
|
|
35
|
+
* (`loadAll`) share it, each supplying its own start predicate.
|
|
36
|
+
*/
|
|
37
|
+
export declare function groupTurns<T>(rows: readonly T[], isStart: (row: T) => boolean): T[][];
|
|
38
|
+
export interface ConversationViewOptions {
|
|
39
|
+
/** Keep the first turn in front of the view: the seed of a report thread. */
|
|
40
|
+
readonly keepFirstTurn?: boolean;
|
|
41
|
+
}
|
|
42
|
+
export interface ConversationView {
|
|
43
|
+
readonly messages: ModelMessage[];
|
|
44
|
+
/** `sources[i]` is the index of the input message that gives `messages[i]`. */
|
|
45
|
+
readonly sources: number[];
|
|
46
|
+
}
|
|
47
|
+
export declare function conversationView(messages: readonly ModelMessage[], options: ConversationViewOptions): ConversationView;
|
|
48
|
+
/** The message with no reasoning part, or `undefined` for an assistant message with no part left. */
|
|
49
|
+
export declare function withoutReasoning(message: ModelMessage): ModelMessage | undefined;
|
|
50
|
+
/** The estimate of the messages, by the count that the store writes to the `tokens` column. */
|
|
51
|
+
export declare function viewTokens(messages: readonly ModelMessage[]): number;
|
|
52
|
+
/** The count of the turns that a drop keeps: the newest turn, and each older turn while the view stays within `budget`. */
|
|
53
|
+
export declare function keptTurnsForDrop(messages: readonly ModelMessage[], options: ConversationViewOptions, budget: number): number;
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The view of a conversation, which the loop sends and `loadRecent` gives: the head, then the latest
|
|
3
|
+
* summary marker, then the body. An exchange message never joins it, and a drop marker keeps the last
|
|
4
|
+
* turns of the body without their reasoning. The loop and the reader call this one function, thus the
|
|
5
|
+
* next turn reads back the prefix that the last request sent.
|
|
6
|
+
*/
|
|
7
|
+
import { compactionExchangeOf, compactionMarkerOf, isSyntheticUserMessage } from "./ai-sdk-message-storage.js";
|
|
8
|
+
import { countTokens } from "./count-tokens.js";
|
|
9
|
+
/**
|
|
10
|
+
* A turn starts on a `user` message that a human actually sent. Two kinds of
|
|
11
|
+
* message carry the `user` role without opening a turn, and both are excluded
|
|
12
|
+
* here:
|
|
13
|
+
*
|
|
14
|
+
* - a tool result, which in AI SDK terms is a `tool`-role message, so a mid-turn
|
|
15
|
+
* tool continuation never matches in the first place;
|
|
16
|
+
* - a message the LOOP or the HOST synthesized (`syntheticUserMessage`) — the
|
|
17
|
+
* truncated-reply nudge the loop inserts mid-turn, and the record an embedder
|
|
18
|
+
* appends between turns for work that happened outside the conversation, such as
|
|
19
|
+
* an analysis run's outcome. Both carry the `user` role for the wire format and
|
|
20
|
+
* neither is user input. Reading one as a boundary would split one turn into
|
|
21
|
+
* two: a drop would keep half a turn, and `retractLastTurn` would cut
|
|
22
|
+
* its tail in the middle of a turn rather than at its head.
|
|
23
|
+
*
|
|
24
|
+
* A host-appended record therefore belongs to the turn preceding it, so a tail
|
|
25
|
+
* retraction that removes that turn removes the record with it. That is the
|
|
26
|
+
* accepted consequence of the exclusion, not a defect — the alternative, letting
|
|
27
|
+
* it open a turn, is the exact failure the marker exists to prevent.
|
|
28
|
+
*
|
|
29
|
+
* `GENUINE_USER_START_SQL` in `thread-history.ts` is the twin of this predicate over stored
|
|
30
|
+
* envelopes; the two are built from the same constants so they cannot drift.
|
|
31
|
+
*/
|
|
32
|
+
export function isGenuineUserStart(message) {
|
|
33
|
+
return message.role === "user" && !isSyntheticUserMessage(message);
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Group rows (oldest-first) into turns at genuine-user-start boundaries.
|
|
37
|
+
* Generic over the row shape so the view rule and the display read
|
|
38
|
+
* (`loadAll`) share it, each supplying its own start predicate.
|
|
39
|
+
*/
|
|
40
|
+
export function groupTurns(rows, isStart) {
|
|
41
|
+
const turns = [];
|
|
42
|
+
for (const row of rows) {
|
|
43
|
+
if (turns.length === 0 || isStart(row)) {
|
|
44
|
+
turns.push([row]);
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
turns[turns.length - 1].push(row);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return turns;
|
|
51
|
+
}
|
|
52
|
+
function isCompactionMessage(message) {
|
|
53
|
+
return compactionMarkerOf(message) !== undefined || compactionExchangeOf(message) !== undefined;
|
|
54
|
+
}
|
|
55
|
+
function startsTurn(entry) {
|
|
56
|
+
return isGenuineUserStart(entry.message);
|
|
57
|
+
}
|
|
58
|
+
function withoutReasoningEntries(entries) {
|
|
59
|
+
return entries.flatMap((entry) => {
|
|
60
|
+
const message = withoutReasoning(entry.message);
|
|
61
|
+
return message === undefined ? [] : [{ message, source: entry.source }];
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
function viewParts(messages, options) {
|
|
65
|
+
// The head ends at a marker or an exchange message too, thus the stored rows and a view that
|
|
66
|
+
// starts with the seed and a summary give the same head.
|
|
67
|
+
let headEnd = 0;
|
|
68
|
+
if (options.keepFirstTurn === true) {
|
|
69
|
+
while (headEnd < messages.length) {
|
|
70
|
+
const message = messages[headEnd];
|
|
71
|
+
if (isCompactionMessage(message) || (headEnd > 0 && isGenuineUserStart(message)))
|
|
72
|
+
break;
|
|
73
|
+
headEnd++;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const head = messages.slice(0, headEnd).map((message, source) => ({ message, source }));
|
|
77
|
+
let front;
|
|
78
|
+
let body = [];
|
|
79
|
+
for (let source = headEnd; source < messages.length; source++) {
|
|
80
|
+
const message = messages[source];
|
|
81
|
+
if (compactionExchangeOf(message) !== undefined)
|
|
82
|
+
continue;
|
|
83
|
+
const marker = compactionMarkerOf(message);
|
|
84
|
+
if (marker?.kind === "summary") {
|
|
85
|
+
front = { message, source };
|
|
86
|
+
body = [];
|
|
87
|
+
}
|
|
88
|
+
else if (marker?.kind === "drop") {
|
|
89
|
+
// The drop changes the prefix that the signature of each kept reasoning part binds.
|
|
90
|
+
const turns = groupTurns(body, startsTurn);
|
|
91
|
+
body = withoutReasoningEntries(turns.slice(Math.max(turns.length - marker.keptTurns, 0)).flat());
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
body.push({ message, source });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return { head, front, body };
|
|
98
|
+
}
|
|
99
|
+
export function conversationView(messages, options) {
|
|
100
|
+
const { head, front, body } = viewParts(messages, options);
|
|
101
|
+
const entries = [...head, ...(front === undefined ? [] : [front]), ...body];
|
|
102
|
+
return { messages: entries.map((entry) => entry.message), sources: entries.map((entry) => entry.source) };
|
|
103
|
+
}
|
|
104
|
+
/** The message with no reasoning part, or `undefined` for an assistant message with no part left. */
|
|
105
|
+
export function withoutReasoning(message) {
|
|
106
|
+
if (message.role !== "assistant" || typeof message.content === "string")
|
|
107
|
+
return message;
|
|
108
|
+
const content = message.content.filter((part) => part.type !== "reasoning" && part.type !== "reasoning-file");
|
|
109
|
+
if (content.length === message.content.length)
|
|
110
|
+
return message;
|
|
111
|
+
return content.length === 0 ? undefined : { ...message, content };
|
|
112
|
+
}
|
|
113
|
+
/** The estimate of the messages, by the count that the store writes to the `tokens` column. */
|
|
114
|
+
export function viewTokens(messages) {
|
|
115
|
+
return messages.reduce((sum, message) => sum + countTokens(message.content), 0);
|
|
116
|
+
}
|
|
117
|
+
/** The count of the turns that a drop keeps: the newest turn, and each older turn while the view stays within `budget`. */
|
|
118
|
+
export function keptTurnsForDrop(messages, options, budget) {
|
|
119
|
+
const { head, front, body } = viewParts(messages, options);
|
|
120
|
+
const turnTokens = groupTurns(body, startsTurn).map((turn) => viewTokens(withoutReasoningEntries(turn).map((entry) => entry.message)));
|
|
121
|
+
let total = viewTokens([...head, ...(front === undefined ? [] : [front])].map((entry) => entry.message));
|
|
122
|
+
let kept = 0;
|
|
123
|
+
for (let t = turnTokens.length - 1; t >= 0; t--) {
|
|
124
|
+
if (kept > 0 && total + turnTokens[t] > budget)
|
|
125
|
+
break;
|
|
126
|
+
total += turnTokens[t];
|
|
127
|
+
kept++;
|
|
128
|
+
}
|
|
129
|
+
return kept;
|
|
130
|
+
}
|
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* windows by token budget without tokenizing on the read path. Counted once
|
|
6
|
-
* at write, applied at load.
|
|
2
|
+
* Token counting for the conversation. The store stamps the count of each message
|
|
3
|
+
* row as its `tokens` column, and the loop estimates the view of a request with the
|
|
4
|
+
* same count. `loadRecent` has no budget.
|
|
7
5
|
*
|
|
8
6
|
* Providers do not publish one shared offline tokenizer,
|
|
9
7
|
* so this uses `js-tiktoken`'s `cl100k_base` BPE. It is an approximation:
|
|
@@ -12,7 +10,7 @@
|
|
|
12
10
|
*/
|
|
13
11
|
import type { ModelMessage } from "ai";
|
|
14
12
|
/**
|
|
15
|
-
* Token count of a message's content.
|
|
16
|
-
*
|
|
13
|
+
* Token count of a message's content. Empty content (an empty array or empty
|
|
14
|
+
* string) counts as `0`.
|
|
17
15
|
*/
|
|
18
16
|
export declare function countTokens(content: ModelMessage["content"]): number;
|
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* windows by token budget without tokenizing on the read path. Counted once
|
|
6
|
-
* at write, applied at load.
|
|
2
|
+
* Token counting for the conversation. The store stamps the count of each message
|
|
3
|
+
* row as its `tokens` column, and the loop estimates the view of a request with the
|
|
4
|
+
* same count. `loadRecent` has no budget.
|
|
7
5
|
*
|
|
8
6
|
* Providers do not publish one shared offline tokenizer,
|
|
9
7
|
* so this uses `js-tiktoken`'s `cl100k_base` BPE. It is an approximation:
|
|
@@ -23,8 +21,8 @@ function enc() {
|
|
|
23
21
|
* the text is prompt text. A file rides the wire as an attachment, and the
|
|
24
22
|
* provider bills it at its own rate, thus its bytes count as `0` — the same rule
|
|
25
23
|
* as a top-level `file` block. A file that is stringified into the count inflates
|
|
26
|
-
* the row of the message by tens of thousands of tokens, and the
|
|
27
|
-
*
|
|
24
|
+
* the row of the message by tens of thousands of tokens, and the loop then
|
|
25
|
+
* compacts the view too early.
|
|
28
26
|
*
|
|
29
27
|
* Each other output arm is plain JSON, thus it is stringified whole.
|
|
30
28
|
*/
|
|
@@ -76,8 +74,8 @@ function tokenizableText(block) {
|
|
|
76
74
|
}
|
|
77
75
|
}
|
|
78
76
|
/**
|
|
79
|
-
* Token count of a message's content.
|
|
80
|
-
*
|
|
77
|
+
* Token count of a message's content. Empty content (an empty array or empty
|
|
78
|
+
* string) counts as `0`.
|
|
81
79
|
*/
|
|
82
80
|
export function countTokens(content) {
|
|
83
81
|
if (typeof content === "string") {
|