@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
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Conversation-scoped store with an explicit two-method interface and
|
|
5
5
|
* nothing else bundled in (no semantic recall, no working memory, no title
|
|
6
|
-
* generation).
|
|
7
|
-
*
|
|
6
|
+
* generation). The read side, `loadRecent`, gives the view of the latest
|
|
7
|
+
* compaction marker: the stored markers decide what a turn sends, and the store
|
|
8
|
+
* deletes no row.
|
|
8
9
|
*
|
|
9
10
|
* Scope (see the harness-thread-store spec): conversation threads only. A `threadId` is the
|
|
10
11
|
* UI-generated id of one conversation thread (a random UUID — an analysis
|
|
@@ -14,11 +15,10 @@
|
|
|
14
15
|
* conversation-turn shaped on purpose, so reaching for it inside a
|
|
15
16
|
* workflow step feels immediately wrong.
|
|
16
17
|
*
|
|
17
|
-
* The
|
|
18
|
-
* `user` message
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* rounds the budget walk to turn boundaries.
|
|
18
|
+
* The view is always a valid AI SDK model-message sequence: it begins on a
|
|
19
|
+
* `user` message — never a `tool`-role continuation — and never splits a
|
|
20
|
+
* tool-call/tool-result pair. A compaction starts only between two rounds, a
|
|
21
|
+
* drop keeps whole turns, and each write of a group is atomic.
|
|
22
22
|
*/
|
|
23
23
|
import { randomUUID } from "node:crypto";
|
|
24
24
|
import { metrics } from "@opentelemetry/api";
|
|
@@ -27,7 +27,8 @@ import { stripNulCharacters } from "../input-sanitization.js";
|
|
|
27
27
|
import { tryMutation, tryQuery, withTransaction } from "../lib/db-result.js";
|
|
28
28
|
import { hasReportedUsage } from "../loop/metrics.js";
|
|
29
29
|
import { countTokens } from "./count-tokens.js";
|
|
30
|
-
import {
|
|
30
|
+
import { conversationView, groupTurns, isGenuineUserStart } from "./conversation-view.js";
|
|
31
|
+
import { HARNESS_PROVIDER_NAMESPACE, SYNTHETIC_MESSAGE_KEY, envelopeMessage, parseStoredMessageEnvelope, syntheticRecordMessage, } from "./ai-sdk-message-storage.js";
|
|
31
32
|
import { envelopeDisplayMessages, parseStoredDisplayEnvelope } from "./conversation-display-storage.js";
|
|
32
33
|
/** A resolved `ok(undefined)` ResultAsync — the empty/seed transaction step. */
|
|
33
34
|
function okVoid() {
|
|
@@ -51,34 +52,8 @@ export function conversationRecordTurn(text) {
|
|
|
51
52
|
};
|
|
52
53
|
}
|
|
53
54
|
/**
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
* here:
|
|
57
|
-
*
|
|
58
|
-
* - a tool result, which in AI SDK terms is a `tool`-role message, so a mid-turn
|
|
59
|
-
* tool continuation never matches in the first place;
|
|
60
|
-
* - a message the LOOP or the HOST synthesized (`syntheticUserMessage`) — the
|
|
61
|
-
* truncated-reply nudge the loop inserts mid-turn, and the record an embedder
|
|
62
|
-
* appends between turns for work that happened outside the conversation, such as
|
|
63
|
-
* an analysis run's outcome. Both carry the `user` role for the wire format and
|
|
64
|
-
* neither is user input. Reading one as a boundary would split one turn into
|
|
65
|
-
* two: the token window would evict half a turn, and `retractLastTurn` would cut
|
|
66
|
-
* its tail in the middle of a turn rather than at its head.
|
|
67
|
-
*
|
|
68
|
-
* A host-appended record therefore belongs to the turn preceding it, so a tail
|
|
69
|
-
* retraction that removes that turn removes the record with it. That is the
|
|
70
|
-
* accepted consequence of the exclusion, not a defect — the alternative, letting
|
|
71
|
-
* it open a turn, is the exact failure the marker exists to prevent.
|
|
72
|
-
*
|
|
73
|
-
* {@link GENUINE_USER_START_SQL} is the twin of this predicate over stored
|
|
74
|
-
* envelopes; the two are built from the same constants so they cannot drift.
|
|
75
|
-
*/
|
|
76
|
-
function isGenuineUserStart(message) {
|
|
77
|
-
return message.role === "user" && !isSyntheticUserMessage(message);
|
|
78
|
-
}
|
|
79
|
-
/**
|
|
80
|
-
* {@link isGenuineUserStart} expressed over a stored `message_envelope` — the
|
|
81
|
-
* boundary predicate `retractLastTurn` cuts on.
|
|
55
|
+
* {@link isGenuineUserStart} (`conversation-view.ts`) expressed over a stored
|
|
56
|
+
* `message_envelope` — the boundary predicate `retractLastTurn` cuts on.
|
|
82
57
|
*
|
|
83
58
|
* Interpolating here, and only here, is safe: both interpolated values are
|
|
84
59
|
* module constants shared with the TypeScript predicate, never caller input, so
|
|
@@ -105,35 +80,17 @@ const GENUINE_USER_START_SQL = `message_envelope->'message'->>'role' = 'user'
|
|
|
105
80
|
function serializeEnvelope(message) {
|
|
106
81
|
return JSON.stringify(envelopeMessage(message), (_key, value) => (typeof value === "string" ? stripNulCharacters(value) : value));
|
|
107
82
|
}
|
|
108
|
-
/**
|
|
109
|
-
* Group rows (oldest-first) into turns at genuine-user-start boundaries.
|
|
110
|
-
* Generic over the row shape so the token-windowed read (`loadRecent`) and
|
|
111
|
-
* the display read (`loadAll`) share it, each supplying its own start
|
|
112
|
-
* predicate.
|
|
113
|
-
*/
|
|
114
|
-
function groupTurns(rows, isStart) {
|
|
115
|
-
const turns = [];
|
|
116
|
-
for (const row of rows) {
|
|
117
|
-
if (turns.length === 0 || isStart(row)) {
|
|
118
|
-
turns.push([row]);
|
|
119
|
-
}
|
|
120
|
-
else {
|
|
121
|
-
turns[turns.length - 1].push(row);
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
return turns;
|
|
125
|
-
}
|
|
126
83
|
let instruments;
|
|
127
84
|
function getInstruments() {
|
|
128
85
|
if (instruments === undefined) {
|
|
129
86
|
const meter = metrics.getMeter("cortex.harness.memory");
|
|
130
87
|
instruments = {
|
|
131
88
|
totalTokens: meter.createHistogram("cortex.harness.thread.total_tokens", {
|
|
132
|
-
description: "Total token count of a conversation thread, sampled on every loadRecent",
|
|
89
|
+
description: "Total estimated token count of the rows of a conversation thread, sampled on every loadRecent",
|
|
133
90
|
unit: "{token}",
|
|
134
91
|
}),
|
|
135
92
|
turnsEvicted: meter.createHistogram("cortex.harness.thread.turns_evicted", {
|
|
136
|
-
description: "Conversation turns
|
|
93
|
+
description: "Conversation turns of which the view of loadRecent holds no message",
|
|
137
94
|
unit: "{turn}",
|
|
138
95
|
}),
|
|
139
96
|
};
|
|
@@ -148,27 +105,115 @@ export function __resetThreadHistoryMetricsForTest() {
|
|
|
148
105
|
instruments = undefined;
|
|
149
106
|
}
|
|
150
107
|
/**
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
* `loadRecent` snaps its eviction count UP to a multiple of this block, so the
|
|
155
|
-
* window's first turn — hence `messages[0]` and the whole tools+system+history
|
|
156
|
-
* prompt-cache prefix — stays byte-identical across a run of appends and shifts
|
|
157
|
-
* only once per block instead of once per turn. That single shift is the only
|
|
158
|
-
* message-cache miss the block costs.
|
|
159
|
-
*
|
|
160
|
-
* The block is at once (a) the cache-miss cadence — one prefix shift per
|
|
161
|
-
* `EVICTION_BLOCK_TURNS` appends once a thread is over budget — and (b) the most
|
|
162
|
-
* extra context ever sacrificed: snapping up drops up to `EVICTION_BLOCK_TURNS -
|
|
163
|
-
* 1` oldest turns the budget alone would have kept, trading a little history for
|
|
164
|
-
* a still prefix. 4 holds the prefix for ~4 turns at a cost of at most 3 turns of
|
|
165
|
-
* headroom — negligible against a ~120k-token budget that spans far more turns,
|
|
166
|
-
* while cutting cache misses on a long thread roughly fourfold. A turn-count
|
|
167
|
-
* block (not a token block) is deliberate: a cache miss is triggered by the START
|
|
168
|
-
* turn moving, a per-turn event, so counting in turns makes the miss cadence
|
|
169
|
-
* exact and independent of how large any individual turn is.
|
|
108
|
+
* Serialize the writes of one thread — without the lock, two transactions can
|
|
109
|
+
* both read the same MAX(seq) and collide on the (thread_id, seq) primary key.
|
|
110
|
+
* Released automatically at COMMIT/ROLLBACK.
|
|
170
111
|
*/
|
|
171
|
-
|
|
112
|
+
function lockThread(client, threadId, op) {
|
|
113
|
+
return tryQuery(`${op}.lock`, () => client.query("SELECT pg_advisory_xact_lock(hashtext($1))", [threadId])).map(() => undefined);
|
|
114
|
+
}
|
|
115
|
+
/** The `seq` of the next row of the thread. Read it under {@link lockThread}. */
|
|
116
|
+
function nextSeq(client, threadId, op) {
|
|
117
|
+
return tryQuery(`${op}.maxSeq`, async () => {
|
|
118
|
+
const { rows } = await client.query("SELECT COALESCE(MAX(seq), -1)::text AS max_seq FROM messages WHERE thread_id = $1", [
|
|
119
|
+
threadId,
|
|
120
|
+
]);
|
|
121
|
+
return Number(rows[0].max_seq) + 1;
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
/** Insert the rows of one group, from `startSeq` on. */
|
|
125
|
+
function insertGroup(client, threadId, startSeq, group, op) {
|
|
126
|
+
const { modelMessages: messages, displayMessages, author } = group;
|
|
127
|
+
// The display projection rides the group's FIRST row, so one SELECT of a
|
|
128
|
+
// thread's rows yields every envelope in order with no join and no grouping.
|
|
129
|
+
// Whichever row that is — a genuine user message opening a turn, or the lone
|
|
130
|
+
// record row of an out-of-band append — is the row a tail retraction removes
|
|
131
|
+
// the envelope with, because it is the row the projection describes.
|
|
132
|
+
const displayEnvelope = displayMessages.length > 0 ? JSON.stringify(envelopeDisplayMessages(displayMessages)) : null;
|
|
133
|
+
// The author rides the row the display projection rides, and only when that
|
|
134
|
+
// row is a message a person actually sent — an assistant row, a tool row,
|
|
135
|
+
// and the synthetic opening row of a record append are not. NUL is dropped
|
|
136
|
+
// rather than refused: a 0x00 byte in a `text` parameter fails the
|
|
137
|
+
// statement, and one byte in a label would roll the whole turn back.
|
|
138
|
+
const turnAuthor = author === undefined ? null : stripNulCharacters(author);
|
|
139
|
+
const authorRow = messages[0] !== undefined && isGenuineUserStart(messages[0]) ? 0 : -1;
|
|
140
|
+
// Insert the group's messages in order. Each insert chains off the
|
|
141
|
+
// prior so the first `err` short-circuits — and `withTransaction`
|
|
142
|
+
// re-throws it to force ROLLBACK (a returned `err` that does not
|
|
143
|
+
// reach `withTransaction` would COMMIT silently).
|
|
144
|
+
return messages.reduce((chain, message, i) => chain.andThen(() => tryMutation(`${op}.insert`, () => client.query(
|
|
145
|
+
// `message_envelope::json`, never `::jsonb`; `display_envelope::jsonb`,
|
|
146
|
+
// never `::json` — see the column comments in the state-init DDL.
|
|
147
|
+
`INSERT INTO messages (thread_id, seq, message_envelope, display_envelope, tokens, author)
|
|
148
|
+
VALUES ($1, $2, $3::json, $4::jsonb, $5, $6)
|
|
149
|
+
ON CONFLICT (thread_id, seq) DO UPDATE
|
|
150
|
+
SET message_envelope = EXCLUDED.message_envelope,
|
|
151
|
+
display_envelope = EXCLUDED.display_envelope,
|
|
152
|
+
tokens = EXCLUDED.tokens,
|
|
153
|
+
author = EXCLUDED.author`, [
|
|
154
|
+
threadId,
|
|
155
|
+
startSeq + i,
|
|
156
|
+
serializeEnvelope(message),
|
|
157
|
+
i === 0 ? displayEnvelope : null,
|
|
158
|
+
countTokens(message.content),
|
|
159
|
+
i === authorRow ? turnAuthor : null,
|
|
160
|
+
])).map(() => undefined)), okVoid());
|
|
161
|
+
}
|
|
162
|
+
/** Move `cortex_analysis_threads.updated_at` forward inside the write transaction. */
|
|
163
|
+
function touchThread(client, threadId, op) {
|
|
164
|
+
// Thread listings sort on `cortex_analysis_threads.updated_at`, and
|
|
165
|
+
// the only other writer of it is the title update — so without this
|
|
166
|
+
// touch "most recently updated" degrades to "most recently created
|
|
167
|
+
// or renamed" and an actively-used older thread sorts last. Writing
|
|
168
|
+
// it from here (a row the thread store otherwise owns) buys the
|
|
169
|
+
// guarantee for every host with no wiring, and inside the turn's own
|
|
170
|
+
// transaction there is no window where the rows exist but the thread
|
|
171
|
+
// reads stale.
|
|
172
|
+
//
|
|
173
|
+
// The breadcrumb is never worth the turn: the touch may fail without
|
|
174
|
+
// failing the append, and affecting zero rows is equally normal — a
|
|
175
|
+
// thread with no metadata row, or a soft-deleted one this leaves
|
|
176
|
+
// alone rather than reviving (every other writer of the table filters
|
|
177
|
+
// the tombstone too). Tolerating the failure takes the savepoint, not
|
|
178
|
+
// just the swallowed `err`: Postgres poisons a transaction at its
|
|
179
|
+
// first failed statement and downgrades the eventual COMMIT to a
|
|
180
|
+
// ROLLBACK — without a rewind point the turn's inserts would go with
|
|
181
|
+
// it, and silently, since that COMMIT still reports success.
|
|
182
|
+
//
|
|
183
|
+
// `GREATEST(updated_at, clock_timestamp())` because `NOW()` is
|
|
184
|
+
// transaction-START time while this transaction has since waited on
|
|
185
|
+
// the advisory lock and spent a round trip per message: a title update
|
|
186
|
+
// that began later can already have stamped a newer `updated_at`, and
|
|
187
|
+
// a plain assignment would rewind it. Activity only ever moves the
|
|
188
|
+
// timestamp forward.
|
|
189
|
+
return tryMutation(`${op}.touchThread.savepoint`, () => client.query("SAVEPOINT touch_thread"))
|
|
190
|
+
.andThen(() => tryMutation(`${op}.touchThread`, () => client.query(`UPDATE cortex_analysis_threads
|
|
191
|
+
SET updated_at = GREATEST(updated_at, clock_timestamp())
|
|
192
|
+
WHERE thread_id = $1 AND deleted_at IS NULL`, [threadId])))
|
|
193
|
+
.map(() => undefined)
|
|
194
|
+
.orElse(() =>
|
|
195
|
+
// Rewind to before the touch so the transaction is usable again
|
|
196
|
+
// and the write commits. Nothing follows in the chain of a caller,
|
|
197
|
+
// so COMMIT releases the savepoint on the success path.
|
|
198
|
+
tryMutation(`${op}.touchThread.rewind`, () => client.query("ROLLBACK TO SAVEPOINT touch_thread"))
|
|
199
|
+
.map(() => undefined)
|
|
200
|
+
.orElse(() => okVoid()));
|
|
201
|
+
}
|
|
202
|
+
/** Add the `open` record of a turn. The upsert replaces a record whose rows are gone, because the opening row holds its key now. */
|
|
203
|
+
function openTurnRecord(client, threadId, startSeq) {
|
|
204
|
+
return tryMutation("thread-history.writeTurn.openRecord", () => client.query(`INSERT INTO cortex_thread_turns (thread_id, start_seq, status)
|
|
205
|
+
VALUES ($1, $2, 'open')
|
|
206
|
+
ON CONFLICT (thread_id, start_seq) DO UPDATE
|
|
207
|
+
SET status = 'open', reason = NULL, reported_usage = NULL, turn_duration_ms = NULL, opened_at = NOW(), closed_at = NULL`, [threadId, startSeq])).map(() => undefined);
|
|
208
|
+
}
|
|
209
|
+
/** Set the outcome of a turn record that is still `open`, thus a second close changes nothing. */
|
|
210
|
+
function closeTurnRecord(client, threadId, startSeq, close) {
|
|
211
|
+
// The predicate of the loop decides whether a rollup reported a quantity, thus the two cannot drift.
|
|
212
|
+
const rollup = hasReportedUsage(close.turnUsage) ? JSON.stringify(close.turnUsage) : null;
|
|
213
|
+
return tryMutation("thread-history.writeTurn.closeRecord", () => client.query(`UPDATE cortex_thread_turns
|
|
214
|
+
SET status = $3, reason = $4, reported_usage = $5::jsonb, turn_duration_ms = $6, closed_at = NOW()
|
|
215
|
+
WHERE thread_id = $1 AND start_seq = $2 AND status = 'open'`, [threadId, startSeq, close.status, close.reason === undefined ? null : stripNulCharacters(close.reason), rollup, close.turnDurationMs ?? null])).map(() => undefined);
|
|
216
|
+
}
|
|
172
217
|
/**
|
|
173
218
|
* Create a `ThreadHistory` bound to a Postgres pool — a factory closure
|
|
174
219
|
* capturing `pool` (dependency injection per the harness-durable-runtime spec). The `messages` table is
|
|
@@ -181,114 +226,36 @@ export const EVICTION_BLOCK_TURNS = 4;
|
|
|
181
226
|
*/
|
|
182
227
|
export function createThreadHistory(pool, logger) {
|
|
183
228
|
function appendTurn(threadId, turn) {
|
|
184
|
-
|
|
185
|
-
if (messages.length === 0)
|
|
229
|
+
if (turn.modelMessages.length === 0)
|
|
186
230
|
return okVoid();
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
// the envelope with, because it is the row the projection describes.
|
|
192
|
-
const displayEnvelope = displayMessages.length > 0 ? JSON.stringify(envelopeDisplayMessages(displayMessages)) : null;
|
|
193
|
-
// A rollup that reports no quantity is stored as absent, not as a rollup of
|
|
194
|
-
// absences, so "no figure" has exactly one representation in storage.
|
|
195
|
-
// `hasReportedUsage` is the loop's own predicate for the same question,
|
|
196
|
-
// reused rather than restated so the write and the loop cannot drift.
|
|
197
|
-
const rollup = hasReportedUsage(turnUsage) ? JSON.stringify(turnUsage) : null;
|
|
198
|
-
// The duration takes no predicate of its own, and it keeps its own column.
|
|
199
|
-
// A measured zero is a figure, thus only an absent value means that nobody
|
|
200
|
-
// measured the turn. The rollup goes absent under its own predicate above,
|
|
201
|
-
// and the duration does not go with it.
|
|
202
|
-
const duration = turnDurationMs ?? null;
|
|
203
|
-
// The two figures describe the turn, and the assistant reply is the row a
|
|
204
|
-
// reader associates with the answer. The LAST assistant row, not any of them: a
|
|
205
|
-
// serial-tool turn writes one assistant row per step and only the last ends
|
|
206
|
-
// the turn. -1 (a turn that persisted no reply — an abort before any output)
|
|
207
|
-
// matches no index below, so nothing is written and the append still succeeds.
|
|
208
|
-
const lastAssistantRow = messages.reduce((last, m, i) => (m.role === "assistant" ? i : last), -1);
|
|
209
|
-
// The author rides the row the display projection rides, and only when that
|
|
210
|
-
// row is a message a person actually sent — an assistant row, a tool row,
|
|
211
|
-
// and the synthetic opening row of a record append are not. NUL is dropped
|
|
212
|
-
// rather than refused: a 0x00 byte in a `text` parameter fails the
|
|
213
|
-
// statement, and one byte in a label would roll the whole turn back.
|
|
214
|
-
const turnAuthor = author === undefined ? null : stripNulCharacters(author);
|
|
215
|
-
const authorRow = messages[0] !== undefined && isGenuineUserStart(messages[0]) ? 0 : -1;
|
|
216
|
-
return withTransaction(pool, "thread-history.appendTurn", (client) =>
|
|
217
|
-
// Serialize concurrent appends on this thread — without the lock, two
|
|
218
|
-
// transactions can both read the same MAX(seq) and collide on the
|
|
219
|
-
// (thread_id, seq) primary key. Released automatically at COMMIT/ROLLBACK.
|
|
220
|
-
tryQuery("thread-history.appendTurn.lock", () => client.query("SELECT pg_advisory_xact_lock(hashtext($1))", [threadId]))
|
|
221
|
-
.andThen(() => tryQuery("thread-history.appendTurn.maxSeq", async () => {
|
|
222
|
-
const { rows } = await client.query("SELECT COALESCE(MAX(seq), -1)::text AS max_seq FROM messages WHERE thread_id = $1", [threadId]);
|
|
223
|
-
return Number(rows[0].max_seq) + 1;
|
|
224
|
-
}))
|
|
225
|
-
.andThen((startSeq) =>
|
|
226
|
-
// Insert the turn's messages in order. Each insert chains off the
|
|
227
|
-
// prior so the first `err` short-circuits — and `withTransaction`
|
|
228
|
-
// re-throws it to force ROLLBACK (a returned `err` that does not
|
|
229
|
-
// reach `withTransaction` would COMMIT silently).
|
|
230
|
-
messages.reduce((chain, message, i) => chain.andThen(() => tryMutation("thread-history.appendTurn.insert", () => client.query(
|
|
231
|
-
// `message_envelope::json`, never `::jsonb`; `display_envelope` and
|
|
232
|
-
// `reported_usage::jsonb`, never `::json` — see the column comments
|
|
233
|
-
// in the state-init DDL.
|
|
234
|
-
`INSERT INTO messages (thread_id, seq, message_envelope, display_envelope, tokens, reported_usage, turn_duration_ms, author)
|
|
235
|
-
VALUES ($1, $2, $3::json, $4::jsonb, $5, $6::jsonb, $7, $8)
|
|
236
|
-
ON CONFLICT (thread_id, seq) DO UPDATE
|
|
237
|
-
SET message_envelope = EXCLUDED.message_envelope,
|
|
238
|
-
display_envelope = EXCLUDED.display_envelope,
|
|
239
|
-
tokens = EXCLUDED.tokens,
|
|
240
|
-
reported_usage = EXCLUDED.reported_usage,
|
|
241
|
-
turn_duration_ms = EXCLUDED.turn_duration_ms,
|
|
242
|
-
author = EXCLUDED.author`, [
|
|
243
|
-
threadId,
|
|
244
|
-
startSeq + i,
|
|
245
|
-
serializeEnvelope(message),
|
|
246
|
-
i === 0 ? displayEnvelope : null,
|
|
247
|
-
countTokens(message.content),
|
|
248
|
-
i === lastAssistantRow ? rollup : null,
|
|
249
|
-
i === lastAssistantRow ? duration : null,
|
|
250
|
-
i === authorRow ? turnAuthor : null,
|
|
251
|
-
])).map(() => undefined)), okVoid()))
|
|
252
|
-
.andThen(() =>
|
|
253
|
-
// Thread listings sort on `cortex_analysis_threads.updated_at`, and
|
|
254
|
-
// the only other writer of it is the title update — so without this
|
|
255
|
-
// touch "most recently updated" degrades to "most recently created
|
|
256
|
-
// or renamed" and an actively-used older thread sorts last. Writing
|
|
257
|
-
// it from here (a row the thread store otherwise owns) buys the
|
|
258
|
-
// guarantee for every host with no wiring, and inside the turn's own
|
|
259
|
-
// transaction there is no window where the rows exist but the thread
|
|
260
|
-
// reads stale.
|
|
261
|
-
//
|
|
262
|
-
// The breadcrumb is never worth the turn: the touch may fail without
|
|
263
|
-
// failing the append, and affecting zero rows is equally normal — a
|
|
264
|
-
// thread with no metadata row, or a soft-deleted one this leaves
|
|
265
|
-
// alone rather than reviving (every other writer of the table filters
|
|
266
|
-
// the tombstone too). Tolerating the failure takes the savepoint, not
|
|
267
|
-
// just the swallowed `err`: Postgres poisons a transaction at its
|
|
268
|
-
// first failed statement and downgrades the eventual COMMIT to a
|
|
269
|
-
// ROLLBACK — without a rewind point the turn's inserts would go with
|
|
270
|
-
// it, and silently, since that COMMIT still reports success.
|
|
271
|
-
//
|
|
272
|
-
// `GREATEST(updated_at, clock_timestamp())` because `NOW()` is
|
|
273
|
-
// transaction-START time while this transaction has since waited on
|
|
274
|
-
// the advisory lock and spent a round trip per message: a title update
|
|
275
|
-
// that began later can already have stamped a newer `updated_at`, and
|
|
276
|
-
// a plain assignment would rewind it. Activity only ever moves the
|
|
277
|
-
// timestamp forward.
|
|
278
|
-
tryMutation("thread-history.appendTurn.touchThread.savepoint", () => client.query("SAVEPOINT touch_thread"))
|
|
279
|
-
.andThen(() => tryMutation("thread-history.appendTurn.touchThread", () => client.query(`UPDATE cortex_analysis_threads
|
|
280
|
-
SET updated_at = GREATEST(updated_at, clock_timestamp())
|
|
281
|
-
WHERE thread_id = $1 AND deleted_at IS NULL`, [threadId])))
|
|
282
|
-
.map(() => undefined)
|
|
283
|
-
.orElse(() =>
|
|
284
|
-
// Rewind to before the touch so the transaction is usable again
|
|
285
|
-
// and the turn commits. Nothing follows in this chain, so COMMIT
|
|
286
|
-
// releases the savepoint on the success path.
|
|
287
|
-
tryMutation("thread-history.appendTurn.touchThread.rewind", () => client.query("ROLLBACK TO SAVEPOINT touch_thread"))
|
|
288
|
-
.map(() => undefined)
|
|
289
|
-
.orElse(() => okVoid()))));
|
|
231
|
+
return withTransaction(pool, "thread-history.appendTurn", (client) => lockThread(client, threadId, "thread-history.appendTurn")
|
|
232
|
+
.andThen(() => nextSeq(client, threadId, "thread-history.appendTurn"))
|
|
233
|
+
.andThen((startSeq) => insertGroup(client, threadId, startSeq, turn, "thread-history.appendTurn"))
|
|
234
|
+
.andThen(() => touchThread(client, threadId, "thread-history.appendTurn")));
|
|
290
235
|
}
|
|
291
|
-
function
|
|
236
|
+
function writeTurn(threadId, write) {
|
|
237
|
+
const { close } = write;
|
|
238
|
+
const groups = [...(write.opening === undefined ? [] : [write.opening]), ...write.rounds, ...(close?.note === undefined ? [] : [close.note])];
|
|
239
|
+
return withTransaction(pool, "thread-history.writeTurn", (client) => lockThread(client, threadId, "thread-history.writeTurn")
|
|
240
|
+
.andThen(() => nextSeq(client, threadId, "thread-history.writeTurn"))
|
|
241
|
+
.andThen((firstSeq) => {
|
|
242
|
+
const startSeq = write.opening === undefined ? write.startSeq : firstSeq;
|
|
243
|
+
const steps = [];
|
|
244
|
+
let seq = firstSeq;
|
|
245
|
+
for (const [index, group] of groups.entries()) {
|
|
246
|
+
const groupSeq = seq;
|
|
247
|
+
seq += group.modelMessages.length;
|
|
248
|
+
steps.push(() => insertGroup(client, threadId, groupSeq, group, "thread-history.writeTurn"));
|
|
249
|
+
if (index === 0 && write.opening !== undefined)
|
|
250
|
+
steps.push(() => openTurnRecord(client, threadId, startSeq));
|
|
251
|
+
}
|
|
252
|
+
if (close !== undefined)
|
|
253
|
+
steps.push(() => closeTurnRecord(client, threadId, startSeq, close));
|
|
254
|
+
steps.push(() => touchThread(client, threadId, "thread-history.writeTurn"));
|
|
255
|
+
return steps.reduce((chain, step) => chain.andThen(step), okVoid()).map(() => ({ startSeq }));
|
|
256
|
+
}));
|
|
257
|
+
}
|
|
258
|
+
function loadRecent(threadId, options) {
|
|
292
259
|
return tryQuery("thread-history.loadRecent", async () => {
|
|
293
260
|
const { rows } = await pool.query(
|
|
294
261
|
// ORDER BY must qualify `messages.seq` — a bare `seq` would bind to the
|
|
@@ -297,72 +264,26 @@ export function createThreadHistory(pool, logger) {
|
|
|
297
264
|
// "10" before "2". Scrambled order splits a tool-call/tool-result pair
|
|
298
265
|
// across an intervening turn. The qualified name forces the bigint column.
|
|
299
266
|
//
|
|
300
|
-
// `reported_usage` is deliberately NOT selected.
|
|
301
|
-
// `tokens`
|
|
302
|
-
//
|
|
303
|
-
//
|
|
304
|
-
// different measurements sharing a unit; see the column comments.
|
|
267
|
+
// `reported_usage` is deliberately NOT selected. The metric reads the
|
|
268
|
+
// `tokens` estimate that every row carries, and a turn that reported
|
|
269
|
+
// nothing has no rollup at all. The two are different measurements
|
|
270
|
+
// sharing a unit; see the column comments.
|
|
305
271
|
`SELECT seq::text AS seq, message_envelope, tokens
|
|
306
272
|
FROM messages WHERE thread_id = $1 ORDER BY messages.seq ASC`, [threadId]);
|
|
307
|
-
const parsed = rows.map((row) => ({
|
|
273
|
+
const parsed = rows.map((row, index) => ({
|
|
274
|
+
index,
|
|
308
275
|
message: parseStoredMessageEnvelope(row.message_envelope, `${threadId}/${row.seq}`).message,
|
|
309
276
|
tokens: row.tokens,
|
|
310
277
|
}));
|
|
278
|
+
const view = conversationView(parsed.map((row) => row.message), options ?? {});
|
|
279
|
+
const held = new Set(view.sources);
|
|
311
280
|
const turns = groupTurns(parsed, (row) => isGenuineUserStart(row.message));
|
|
312
|
-
const
|
|
313
|
-
const
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
// rewrites the whole message cache instead of reading it back (watch
|
|
319
|
-
// `cortex.harness.agent.cache_read_tokens` collapse on long threads).
|
|
320
|
-
// Instead, take the budget-minimal eviction and snap it UP to a whole
|
|
321
|
-
// `EVICTION_BLOCK_TURNS` block: `evicted` stays constant while the minimum
|
|
322
|
-
// sits inside a block and jumps by a block when it crosses, so
|
|
323
|
-
// `messages[0]` is byte-identical for a block of appends (the cache reads
|
|
324
|
-
// survive) and shifts only once per block. Snapping UP retains a subset of
|
|
325
|
-
// the budget-minimal window, so the kept tokens never exceed `tokenBudget`.
|
|
326
|
-
//
|
|
327
|
-
// Budget-minimal fill: walk newest-first, always keeping the most recent
|
|
328
|
-
// turn (a valid over-budget single turn beats an under-budget cut), then
|
|
329
|
-
// add older turns while they fit.
|
|
330
|
-
let included = 0;
|
|
331
|
-
let cumulative = 0;
|
|
332
|
-
for (let t = turns.length - 1; t >= 0; t--) {
|
|
333
|
-
if (included > 0 && cumulative + turnTokens[t] > tokenBudget)
|
|
334
|
-
break;
|
|
335
|
-
cumulative += turnTokens[t];
|
|
336
|
-
included++;
|
|
337
|
-
}
|
|
338
|
-
const minimalEvicted = turns.length - included;
|
|
339
|
-
// Snap the eviction count up to a block boundary, clamped below the last
|
|
340
|
-
// turn so the most recent turn always ships. Both terms of the min are
|
|
341
|
-
// >= `minimalEvicted`, so `turnsEvicted >= minimalEvicted`: the retained
|
|
342
|
-
// suffix is a subset of the budget-minimal one and stays within budget.
|
|
343
|
-
const turnsEvicted = turns.length === 0 ? 0 : Math.min(Math.ceil(minimalEvicted / EVICTION_BLOCK_TURNS) * EVICTION_BLOCK_TURNS, turns.length - 1);
|
|
344
|
-
// The retained head. The first turn of a report session carries the
|
|
345
|
-
// brief and the copy of the working-memory render, and no tail message
|
|
346
|
-
// replaces them. Thus `keepFirstTurn` puts that turn in front of the
|
|
347
|
-
// retained suffix, and `messages[0]` never moves again. An eviction of
|
|
348
|
-
// zero already holds the first turn at the head, thus the option adds
|
|
349
|
-
// nothing there and the window never carries the turn two times.
|
|
350
|
-
const keepsFirstTurn = options?.keepFirstTurn === true && turnsEvicted > 0;
|
|
351
|
-
const { totalTokens, turnsEvicted: turnsEvictedHist } = getInstruments();
|
|
352
|
-
// A retained head gives one evicted turn back to the window, thus the
|
|
353
|
-
// histogram reports one turn less than the budget walk cut.
|
|
354
|
-
const turnsDropped = keepsFirstTurn ? turnsEvicted - 1 : turnsEvicted;
|
|
355
|
-
// The flag reads the cut of the budget walk, and never the count that
|
|
356
|
-
// the retained head reduced. The two answer different questions: the
|
|
357
|
-
// flag says that the window lost a turn, and the histogram says how
|
|
358
|
-
// many. A walk that cut one block, under a block size of one, drops
|
|
359
|
-
// its whole cut back into the window. The flag must still report the
|
|
360
|
-
// eviction, because the thread crossed its budget.
|
|
361
|
-
const attributes = { eviction: turnsEvicted > 0 };
|
|
362
|
-
totalTokens.record(threadTotal, attributes);
|
|
363
|
-
turnsEvictedHist.record(turnsDropped, attributes);
|
|
364
|
-
const retained = turns.slice(turnsEvicted);
|
|
365
|
-
return (keepsFirstTurn ? [turns[0], ...retained] : retained).flat().map((row) => row.message);
|
|
281
|
+
const turnsLeftOut = turns.filter((turn) => !turn.some((row) => held.has(row.index))).length;
|
|
282
|
+
const { totalTokens, turnsEvicted } = getInstruments();
|
|
283
|
+
const attributes = { eviction: turnsLeftOut > 0 };
|
|
284
|
+
totalTokens.record(parsed.reduce((sum, row) => sum + row.tokens, 0), attributes);
|
|
285
|
+
turnsEvicted.record(turnsLeftOut, attributes);
|
|
286
|
+
return view.messages;
|
|
366
287
|
});
|
|
367
288
|
}
|
|
368
289
|
// Display reads are turn-bounded, not row-bounded. A serial-tool assistant
|
|
@@ -377,8 +298,13 @@ export function createThreadHistory(pool, logger) {
|
|
|
377
298
|
// `seq::text AS seq` output alias (Postgres resolves an unqualified
|
|
378
299
|
// ORDER BY name to the output column), sorting the bigint as text:
|
|
379
300
|
// "10" before "2". The qualified name forces the bigint column.
|
|
380
|
-
`SELECT seq::text AS seq, message_envelope, display_envelope, reported_usage,
|
|
381
|
-
|
|
301
|
+
`SELECT messages.seq::text AS seq, messages.message_envelope, messages.display_envelope, messages.reported_usage,
|
|
302
|
+
messages.turn_duration_ms::text AS turn_duration_ms, messages.author, messages.created_at,
|
|
303
|
+
turns.status AS turn_status, turns.reason AS turn_reason, turns.reported_usage AS turn_usage,
|
|
304
|
+
turns.turn_duration_ms::text AS turn_record_duration_ms
|
|
305
|
+
FROM messages
|
|
306
|
+
LEFT JOIN cortex_thread_turns turns ON turns.thread_id = messages.thread_id AND turns.start_seq = messages.seq
|
|
307
|
+
WHERE messages.thread_id = $1
|
|
382
308
|
ORDER BY messages.seq ASC`, [threadId]);
|
|
383
309
|
// Spread rather than `usage: r.reported_usage ?? undefined`, so a row with
|
|
384
310
|
// no rollup carries no `usage` KEY at all — absent, not present-and-undefined.
|
|
@@ -388,6 +314,14 @@ export function createThreadHistory(pool, logger) {
|
|
|
388
314
|
const stored = await Promise.all(rows.map(async (r) => {
|
|
389
315
|
const envelope = parseStoredMessageEnvelope(r.message_envelope, `${threadId}/${r.seq}`);
|
|
390
316
|
const displayEnvelope = r.display_envelope == null ? undefined : await parseStoredDisplayEnvelope(r.display_envelope, `${threadId}/${r.seq}/display`, logger);
|
|
317
|
+
const turn = r.turn_status === null
|
|
318
|
+
? undefined
|
|
319
|
+
: {
|
|
320
|
+
status: r.turn_status,
|
|
321
|
+
...(r.turn_reason === null ? {} : { reason: r.turn_reason }),
|
|
322
|
+
...(r.turn_usage === null ? {} : { usage: r.turn_usage }),
|
|
323
|
+
...(r.turn_record_duration_ms === null ? {} : { durationMs: Number(r.turn_record_duration_ms) }),
|
|
324
|
+
};
|
|
391
325
|
return {
|
|
392
326
|
seq: Number(r.seq),
|
|
393
327
|
envelope,
|
|
@@ -395,6 +329,7 @@ export function createThreadHistory(pool, logger) {
|
|
|
395
329
|
...(displayEnvelope ? { displayEnvelope } : {}),
|
|
396
330
|
...(r.reported_usage === null ? {} : { usage: r.reported_usage }),
|
|
397
331
|
...(r.turn_duration_ms === null ? {} : { durationMs: Number(r.turn_duration_ms) }),
|
|
332
|
+
...(turn === undefined ? {} : { turn }),
|
|
398
333
|
...(r.author === null ? {} : { author: r.author }),
|
|
399
334
|
createdAt: r.created_at,
|
|
400
335
|
};
|
|
@@ -444,7 +379,7 @@ export function createThreadHistory(pool, logger) {
|
|
|
444
379
|
// Delete the whole tail turn: every row at or past the boundary seq.
|
|
445
380
|
// `$2::bigint` compares the bigint column against a bigint, never a
|
|
446
381
|
// text projection, keeping the comparison exact for large seqs.
|
|
447
|
-
return tryMutation("thread-history.retractLastTurn.delete", () => client.query("DELETE FROM messages WHERE thread_id = $1 AND seq >= $2::bigint", [threadId, boundary])).
|
|
382
|
+
return tryMutation("thread-history.retractLastTurn.delete", () => client.query("DELETE FROM messages WHERE thread_id = $1 AND seq >= $2::bigint", [threadId, boundary])).andThen((res) => tryMutation("thread-history.retractLastTurn.deleteTurnRecords", () => client.query("DELETE FROM cortex_thread_turns WHERE thread_id = $1 AND start_seq >= $2::bigint", [threadId, boundary])).map(() => ({ kind: "retracted", messages: res.rowCount ?? 0 })));
|
|
448
383
|
}));
|
|
449
384
|
}
|
|
450
385
|
function latestSeq(threadId) {
|
|
@@ -481,5 +416,5 @@ export function createThreadHistory(pool, logger) {
|
|
|
481
416
|
AND seq > $2::bigint
|
|
482
417
|
AND ${GENUINE_USER_START_SQL}`, [threadId, seq])).map(({ rows }) => Number(rows[0].turns));
|
|
483
418
|
}
|
|
484
|
-
return { appendTurn, loadRecent, loadAll, retractLastTurn, latestSeq, latestTurnAt, countUserTurnsAfter };
|
|
419
|
+
return { appendTurn, writeTurn, loadRecent, loadAll, retractLastTurn, latestSeq, latestTurnAt, countUserTurnsAfter };
|
|
485
420
|
}
|
|
@@ -22,8 +22,8 @@
|
|
|
22
22
|
* row and the thread reads as it did; a listing widened with `includeArchived`
|
|
23
23
|
* is the only way to obtain the id it takes, so the recovery is reachable by a
|
|
24
24
|
* host that holds no thread ids it obtained elsewhere.
|
|
25
|
-
* `purgeThread` is not recoverable: it removes the `messages` rows, the
|
|
26
|
-
* session-state rows, and the metadata rows of the whole subtree in one
|
|
25
|
+
* `purgeThread` is not recoverable: it removes the `messages` rows, the turn
|
|
26
|
+
* records, the report session-state rows, the kept tool outputs, and the metadata rows of the whole subtree in one
|
|
27
27
|
* transaction, so a failure partway leaves them all — never a thread stripped of
|
|
28
28
|
* its transcript, nor a transcript with nothing naming it. It is the thread-scoped member of this package's
|
|
29
29
|
* reclamation vocabulary — `purgeAnalysis` reclaims an analysis's whole
|
|
@@ -222,7 +222,7 @@ export interface ThreadStore {
|
|
|
222
222
|
/**
|
|
223
223
|
* Reclaim the subtree's whole footprint: the named thread, every descendant
|
|
224
224
|
* reachable through `parent_thread_id` at any depth, and the `messages` rows
|
|
225
|
-
* of every one of them, removed together in one transaction. Unrecoverable —
|
|
225
|
+
* and the turn records of every one of them, removed together in one transaction. Unrecoverable —
|
|
226
226
|
* nothing survives, and no tombstone marks that any of it existed. A
|
|
227
227
|
* `thread_id` with no row succeeds as a no-op.
|
|
228
228
|
*
|
|
@@ -22,8 +22,8 @@
|
|
|
22
22
|
* row and the thread reads as it did; a listing widened with `includeArchived`
|
|
23
23
|
* is the only way to obtain the id it takes, so the recovery is reachable by a
|
|
24
24
|
* host that holds no thread ids it obtained elsewhere.
|
|
25
|
-
* `purgeThread` is not recoverable: it removes the `messages` rows, the
|
|
26
|
-
* session-state rows, and the metadata rows of the whole subtree in one
|
|
25
|
+
* `purgeThread` is not recoverable: it removes the `messages` rows, the turn
|
|
26
|
+
* records, the report session-state rows, the kept tool outputs, and the metadata rows of the whole subtree in one
|
|
27
27
|
* transaction, so a failure partway leaves them all — never a thread stripped of
|
|
28
28
|
* its transcript, nor a transcript with nothing naming it. It is the thread-scoped member of this package's
|
|
29
29
|
* reclamation vocabulary — `purgeAnalysis` reclaims an analysis's whole
|
|
@@ -295,8 +295,13 @@ export function createThreadStore(pool) {
|
|
|
295
295
|
// set that can still change, and after it the rows are gone.
|
|
296
296
|
return withTransaction(pool, "thread-store.purgeThread", (client) => tryMutation("thread-store.purgeThread.messages", () => client.query(`${SUBTREE_CTE}
|
|
297
297
|
DELETE FROM messages WHERE thread_id IN (SELECT thread_id FROM subtree)`, [threadId]))
|
|
298
|
+
.andThen(() => tryMutation("thread-store.purgeThread.turnRecords", () => client.query(`${SUBTREE_CTE}
|
|
299
|
+
DELETE FROM cortex_thread_turns WHERE thread_id IN (SELECT thread_id FROM subtree)`, [threadId])))
|
|
298
300
|
.andThen(() => tryMutation("thread-store.purgeThread.reportSessionState", () => client.query(`${SUBTREE_CTE}
|
|
299
301
|
DELETE FROM cortex_report_session_state WHERE thread_id IN (SELECT thread_id FROM subtree)`, [threadId])))
|
|
302
|
+
// A kept text of a run names no thread, thus it stays for the analysis purge.
|
|
303
|
+
.andThen(() => tryMutation("thread-store.purgeThread.toolOutputs", () => client.query(`${SUBTREE_CTE}
|
|
304
|
+
DELETE FROM cortex_tool_outputs WHERE thread_id IN (SELECT thread_id FROM subtree)`, [threadId])))
|
|
300
305
|
.andThen(() => tryMutation("thread-store.purgeThread.thread", () => client.query(`${SUBTREE_CTE}
|
|
301
306
|
DELETE FROM cortex_analysis_threads WHERE thread_id IN (SELECT thread_id FROM subtree)
|
|
302
307
|
RETURNING thread_id`, [threadId])))
|
|
@@ -10,37 +10,31 @@
|
|
|
10
10
|
* streams before the stop reason arrives, and only a `tool-calls` (and, for its
|
|
11
11
|
* leading calls, a `length`) finish dispatches it.
|
|
12
12
|
*
|
|
13
|
-
* The repair
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* turn. The prose of the message survives the strip; a message left with
|
|
17
|
-
* nothing the wire can render disappears entirely.
|
|
13
|
+
* The repair only appends an answer; it never edits or removes a message,
|
|
14
|
+
* because a signed thinking block and the prompt cache both key on the exact
|
|
15
|
+
* prior prefix.
|
|
18
16
|
*/
|
|
19
|
-
import type { ModelMessage } from "ai";
|
|
20
|
-
|
|
21
|
-
export
|
|
17
|
+
import type { ModelMessage, ToolCallPart, ToolResultPart } from "ai";
|
|
18
|
+
export declare const NOT_RUN_TOOL_RESULT = "Not run: the turn ended before this call ran.";
|
|
19
|
+
export declare function notRunResult(call: Pick<ToolCallPart, "toolCallId" | "toolName">): ToolResultPart;
|
|
20
|
+
/** One tool call that {@link answerUnansweredToolCalls} answered, identified for the caller's diagnostic record. */
|
|
21
|
+
export interface AnsweredToolCall {
|
|
22
22
|
readonly toolCallId: string;
|
|
23
23
|
readonly toolName: string;
|
|
24
24
|
}
|
|
25
25
|
/**
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* transcript order — empty on the healthy path — so the
|
|
29
|
-
* a reader of the stored thread would otherwise never learn.
|
|
26
|
+
* Answer, in place, every tool call at or past `fromIndex` that has no matching
|
|
27
|
+
* tool result anywhere in `messages`. No message is removed or changed. Returns
|
|
28
|
+
* the answered calls in transcript order — empty on the healthy path — so the
|
|
29
|
+
* caller can log what a reader of the stored thread would otherwise never learn.
|
|
30
30
|
*
|
|
31
31
|
* Results are matched by id across the whole array, not by adjacency: the loop
|
|
32
32
|
* appends a round's results directly after its assistant message, but a
|
|
33
33
|
* deferred-image user message can ride between rounds, and the stored shape is
|
|
34
34
|
* not this function's to assume.
|
|
35
35
|
*
|
|
36
|
-
* A message that keeps a text or file part survives the strip — refusal prose
|
|
37
|
-
* is the one account the reader has of a filtered reply. A message left with
|
|
38
|
-
* no such part (a call-only message, or reasoning beside the removed call) is
|
|
39
|
-
* removed whole: reasoning without the output it preceded is not replayable,
|
|
40
|
-
* and an empty assistant message is itself a wire violation.
|
|
41
|
-
*
|
|
42
36
|
* `fromIndex` bounds the REPAIR, not the result scan. The loop passes its
|
|
43
|
-
* `initial.length` so it never
|
|
44
|
-
*
|
|
37
|
+
* `initial.length` so it never answers inside the caller's prefix; the history
|
|
38
|
+
* load passes nothing and covers the whole loaded window.
|
|
45
39
|
*/
|
|
46
|
-
export declare function
|
|
40
|
+
export declare function answerUnansweredToolCalls(messages: ModelMessage[], fromIndex?: number): AnsweredToolCall[];
|