@inflexa-ai/harness 0.15.0 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agents/sandbox/drug-repurposing-agent.js +5 -1
- package/dist/agents/sandbox/immune-profiling-agent.js +4 -1
- package/dist/contracts/chat-events.d.ts +16 -0
- package/dist/contracts/message.d.ts +13 -8
- package/dist/contracts/schemas/chat-events.d.ts +7 -0
- package/dist/contracts/schemas/chat-events.js +2 -0
- package/dist/index.d.ts +8 -9
- package/dist/index.js +17 -16
- package/dist/memory/content-to-cortex.d.ts +12 -11
- package/dist/memory/content-to-cortex.js +17 -14
- package/dist/memory/conversation-display-backfill.d.ts +25 -0
- package/dist/memory/conversation-display-backfill.js +97 -0
- package/dist/memory/conversation-display-recorder.d.ts +17 -0
- package/dist/memory/conversation-display-recorder.js +156 -0
- package/dist/memory/conversation-display-replay.d.ts +19 -0
- package/dist/memory/conversation-display-replay.js +39 -0
- package/dist/memory/conversation-display-storage.d.ts +86 -0
- package/dist/memory/conversation-display-storage.js +168 -0
- package/dist/memory/reconstruct-cards.d.ts +7 -1
- package/dist/memory/reconstruct-cards.js +5 -1
- package/dist/memory/thread-history.d.ts +46 -11
- package/dist/memory/thread-history.js +55 -13
- package/dist/prompts/sandbox/bulk-transcriptomics-agent.d.ts +1 -1
- package/dist/prompts/sandbox/bulk-transcriptomics-agent.js +17 -13
- package/dist/prompts/sandbox/chromatin-agent.d.ts +1 -1
- package/dist/prompts/sandbox/chromatin-agent.js +45 -54
- package/dist/prompts/sandbox/genomic-variant-agent.d.ts +1 -1
- package/dist/prompts/sandbox/genomic-variant-agent.js +39 -37
- package/dist/prompts/sandbox-standards.d.ts +1 -1
- package/dist/prompts/sandbox-standards.js +21 -2
- package/dist/reference-data/catalog.js +536 -0
- package/dist/runtime/boot.d.ts +8 -2
- package/dist/runtime/boot.js +14 -2
- package/dist/state/init.js +4 -0
- package/package.json +1 -1
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The runtime transcript read: stored display projections → `CortexMessage[]`.
|
|
3
|
+
*
|
|
4
|
+
* `ThreadHistory.loadPage` returns a thread's rows in `seq` order, and every
|
|
5
|
+
* append's first row carries the display projection for that append. Replay is
|
|
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
|
+
* startup backfill (`conversation-display-backfill.ts`) writes one for every such
|
|
13
|
+
* row before the runtime serves traffic, so in a booted harness there are none;
|
|
14
|
+
* skipping rather than falling back to the migration renderer is what keeps the
|
|
15
|
+
* reconstruction path out of the runtime entirely.
|
|
16
|
+
*/
|
|
17
|
+
import { conversationUIToCortexMessages } from "./conversation-display-storage.js";
|
|
18
|
+
export function storedMessagesToCortex(messages) {
|
|
19
|
+
const out = [];
|
|
20
|
+
// The messages the append currently being walked produced. A row bearing an
|
|
21
|
+
// envelope opens one; every row after it belongs to it until the next does.
|
|
22
|
+
let append = null;
|
|
23
|
+
for (const row of messages) {
|
|
24
|
+
if (row.displayEnvelope) {
|
|
25
|
+
append = conversationUIToCortexMessages(row.displayEnvelope.messages);
|
|
26
|
+
out.push(...append);
|
|
27
|
+
}
|
|
28
|
+
// The turn's reported rollup rides the model row that ENDED the turn, not the
|
|
29
|
+
// display projection — it is a fact about what the turn cost, not about what it
|
|
30
|
+
// showed, and persisting it in both places would let the two disagree. Fold it
|
|
31
|
+
// onto the assistant reply a reader associates with the figure.
|
|
32
|
+
if (row.usage && append) {
|
|
33
|
+
const last = append.at(-1);
|
|
34
|
+
if (last?.role === "assistant")
|
|
35
|
+
last.usage = row.usage;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The durable conversation display projection — what the user was shown,
|
|
3
|
+
* persisted verbatim.
|
|
4
|
+
*
|
|
5
|
+
* AI SDK `UIMessage` is the CONTAINER: it supplies message identity, role,
|
|
6
|
+
* ordering, metadata, and `validateUIMessages` for runtime validation. The PARTS
|
|
7
|
+
* vocabulary is Cortex's own, because ours is the richer one — a `CortexPart`
|
|
8
|
+
* carries a tool call's four-way outcome and its one-line detail, and AI SDK's
|
|
9
|
+
* `dynamic-tool` part has nowhere to put either. Mapping through `dynamic-tool`
|
|
10
|
+
* would silently drop both on every reload, so every non-text part rides as a
|
|
11
|
+
* typed `DataUIPart` whose payload IS the Cortex part minus its discriminant.
|
|
12
|
+
*
|
|
13
|
+
* The projection is therefore lossless in both directions, and the read path
|
|
14
|
+
* ({@link conversationUIToCortexMessages}) is a near-identity rather than a
|
|
15
|
+
* reconstruction.
|
|
16
|
+
*/
|
|
17
|
+
import { type DataUIPart, type UIMessage } from "ai";
|
|
18
|
+
import type { AskPart, FileReferencePart, PlanPart, PresentationPart, PreviewPart, DataPreviewFailedPart, RunCardPart } from "../contracts/chat-parts.js";
|
|
19
|
+
import type { ToolCallOutcome } from "../contracts/chat-events.js";
|
|
20
|
+
import type { CortexMessage, CortexPart, ToolCallPart } from "../contracts/message.js";
|
|
21
|
+
export declare const SUPPORTED_DISPLAY_SCHEMA_VERSION = 1;
|
|
22
|
+
type Payload<T extends {
|
|
23
|
+
type: string;
|
|
24
|
+
}> = Omit<T, "type">;
|
|
25
|
+
/**
|
|
26
|
+
* The AI SDK custom-data vocabulary persisted for one conversation display.
|
|
27
|
+
*
|
|
28
|
+
* Each key `k` becomes a `data-${k}` part whose payload is the matching Cortex
|
|
29
|
+
* part without its `type` field, so a stored part and a wire part differ only in
|
|
30
|
+
* where the discriminant lives.
|
|
31
|
+
*/
|
|
32
|
+
export type ConversationUIData = {
|
|
33
|
+
"tool-call": Omit<Payload<ToolCallPart>, "outcome"> & {
|
|
34
|
+
outcome: ToolCallOutcome;
|
|
35
|
+
};
|
|
36
|
+
presentation: Payload<PresentationPart>;
|
|
37
|
+
plan: Payload<PlanPart>;
|
|
38
|
+
"run-card": Payload<RunCardPart>;
|
|
39
|
+
"file-reference": Payload<FileReferencePart>;
|
|
40
|
+
ask: Payload<AskPart>;
|
|
41
|
+
"report-preview": Payload<PreviewPart>;
|
|
42
|
+
"report-preview-failed": Payload<DataPreviewFailedPart>;
|
|
43
|
+
};
|
|
44
|
+
export interface ConversationDisplayMetadata {
|
|
45
|
+
interrupted?: boolean;
|
|
46
|
+
}
|
|
47
|
+
export type ConversationUIMessage = UIMessage<ConversationDisplayMetadata, ConversationUIData>;
|
|
48
|
+
export interface StoredDisplayEnvelope {
|
|
49
|
+
readonly kind: "ai-sdk-ui-messages";
|
|
50
|
+
readonly aiSdkMajor: 7;
|
|
51
|
+
readonly schemaVersion: typeof SUPPORTED_DISPLAY_SCHEMA_VERSION;
|
|
52
|
+
readonly messages: ConversationUIMessage[];
|
|
53
|
+
}
|
|
54
|
+
export declare function envelopeDisplayMessages(messages: readonly ConversationUIMessage[]): StoredDisplayEnvelope;
|
|
55
|
+
export declare function parseStoredDisplayEnvelope(value: unknown, identity: string): Promise<StoredDisplayEnvelope>;
|
|
56
|
+
/**
|
|
57
|
+
* Build the stored form of one non-text Cortex part.
|
|
58
|
+
*
|
|
59
|
+
* A tool call is the one part whose stored payload is not simply the Cortex part
|
|
60
|
+
* minus its discriminant: `outcome` is optional on the live part (absent means in
|
|
61
|
+
* flight) and required here, because a record is of a call that is no longer
|
|
62
|
+
* running. An in-flight call being recorded is one the turn never saw finish, so
|
|
63
|
+
* absence resolves to `incomplete` at exactly this boundary — once, where the
|
|
64
|
+
* lifetime changes — rather than at each reader.
|
|
65
|
+
*/
|
|
66
|
+
export declare function conversationDisplayPart(part: Exclude<CortexPart, {
|
|
67
|
+
type: "text";
|
|
68
|
+
}>): DataUIPart<ConversationUIData>;
|
|
69
|
+
/**
|
|
70
|
+
* Project Cortex display messages into the durable representation.
|
|
71
|
+
*
|
|
72
|
+
* The startup backfill's only path into storage: it renders a legacy turn from
|
|
73
|
+
* the model transcript and freezes the result. The live recorder builds the same
|
|
74
|
+
* shape directly from the event stream instead of round-tripping through
|
|
75
|
+
* `CortexMessage`.
|
|
76
|
+
*/
|
|
77
|
+
export declare function cortexMessagesToConversationUI(messages: readonly CortexMessage[]): ConversationUIMessage[];
|
|
78
|
+
/**
|
|
79
|
+
* Read the durable representation back as Cortex display messages.
|
|
80
|
+
*
|
|
81
|
+
* Every part round-trips by moving its discriminant back out of the part type,
|
|
82
|
+
* so nothing here consults a tool name, a registry, or the filesystem. A message
|
|
83
|
+
* whose parts all render empty is dropped so the transcript has no empty bubbles.
|
|
84
|
+
*/
|
|
85
|
+
export declare function conversationUIToCortexMessages(messages: readonly ConversationUIMessage[]): CortexMessage[];
|
|
86
|
+
export {};
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The durable conversation display projection — what the user was shown,
|
|
3
|
+
* persisted verbatim.
|
|
4
|
+
*
|
|
5
|
+
* AI SDK `UIMessage` is the CONTAINER: it supplies message identity, role,
|
|
6
|
+
* ordering, metadata, and `validateUIMessages` for runtime validation. The PARTS
|
|
7
|
+
* vocabulary is Cortex's own, because ours is the richer one — a `CortexPart`
|
|
8
|
+
* carries a tool call's four-way outcome and its one-line detail, and AI SDK's
|
|
9
|
+
* `dynamic-tool` part has nowhere to put either. Mapping through `dynamic-tool`
|
|
10
|
+
* would silently drop both on every reload, so every non-text part rides as a
|
|
11
|
+
* typed `DataUIPart` whose payload IS the Cortex part minus its discriminant.
|
|
12
|
+
*
|
|
13
|
+
* The projection is therefore lossless in both directions, and the read path
|
|
14
|
+
* ({@link conversationUIToCortexMessages}) is a near-identity rather than a
|
|
15
|
+
* reconstruction.
|
|
16
|
+
*/
|
|
17
|
+
import { validateUIMessages } from "ai";
|
|
18
|
+
import { z } from "zod";
|
|
19
|
+
import { AskPartSchema, DataPreviewFailedPartSchema, FileReferencePartSchema, PlanPartSchema, PresentationPartSchema, PreviewPartSchema, RunCardPartSchema, } from "../contracts/schemas/chat-parts.js";
|
|
20
|
+
import { ToolCallOutcomeSchema } from "../contracts/schemas/chat-events.js";
|
|
21
|
+
export const SUPPORTED_DISPLAY_SCHEMA_VERSION = 1;
|
|
22
|
+
const DisplayEnvelopeHeaderSchema = z.object({
|
|
23
|
+
kind: z.literal("ai-sdk-ui-messages"),
|
|
24
|
+
aiSdkMajor: z.literal(7),
|
|
25
|
+
schemaVersion: z.literal(SUPPORTED_DISPLAY_SCHEMA_VERSION),
|
|
26
|
+
messages: z.array(z.unknown()).min(1),
|
|
27
|
+
});
|
|
28
|
+
/**
|
|
29
|
+
* Declared here rather than derived from `contracts/schemas/chat-parts.ts`: a
|
|
30
|
+
* tool call is not a chat DATA part on the wire — it reaches a host as the
|
|
31
|
+
* `tool-started`/`tool-finished` event pair — so `chat-parts.ts` has no member to
|
|
32
|
+
* omit a discriminant from. Storage is the only place the pair is flattened into
|
|
33
|
+
* one part, and this is that shape.
|
|
34
|
+
*
|
|
35
|
+
* `outcome` is REQUIRED, and carries the whole terminal state including
|
|
36
|
+
* `incomplete`. A record describes a call that is no longer running, so "still in
|
|
37
|
+
* flight" is not a state it can be in — a turn cut off mid-call recorded a
|
|
38
|
+
* dispatch with no completion, which is what `incomplete` says. Storing a
|
|
39
|
+
* lifecycle field beside an optional outcome instead would put the meaning of the
|
|
40
|
+
* combination in each reader's head, and readers would disagree.
|
|
41
|
+
*/
|
|
42
|
+
const ToolCallDisplaySchema = z
|
|
43
|
+
.object({
|
|
44
|
+
toolCallId: z.string(),
|
|
45
|
+
toolName: z.string(),
|
|
46
|
+
outcome: ToolCallOutcomeSchema,
|
|
47
|
+
detail: z.string().optional(),
|
|
48
|
+
})
|
|
49
|
+
.strict();
|
|
50
|
+
const dataSchemas = {
|
|
51
|
+
"tool-call": ToolCallDisplaySchema,
|
|
52
|
+
presentation: PresentationPartSchema.omit({ type: true }).strict(),
|
|
53
|
+
plan: PlanPartSchema.omit({ type: true }).strict(),
|
|
54
|
+
"run-card": RunCardPartSchema.omit({ type: true }).strict(),
|
|
55
|
+
"file-reference": FileReferencePartSchema.omit({ type: true }).strict(),
|
|
56
|
+
ask: AskPartSchema.omit({ type: true }).strict(),
|
|
57
|
+
"report-preview": PreviewPartSchema.omit({ type: true }).strict(),
|
|
58
|
+
"report-preview-failed": DataPreviewFailedPartSchema.omit({ type: true }).strict(),
|
|
59
|
+
};
|
|
60
|
+
export function envelopeDisplayMessages(messages) {
|
|
61
|
+
if (messages.length === 0)
|
|
62
|
+
throw new Error("A conversation display envelope must contain at least one UI message");
|
|
63
|
+
return {
|
|
64
|
+
kind: "ai-sdk-ui-messages",
|
|
65
|
+
aiSdkMajor: 7,
|
|
66
|
+
schemaVersion: SUPPORTED_DISPLAY_SCHEMA_VERSION,
|
|
67
|
+
messages: structuredClone(messages),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
export async function parseStoredDisplayEnvelope(value, identity) {
|
|
71
|
+
const header = DisplayEnvelopeHeaderSchema.safeParse(value);
|
|
72
|
+
if (!header.success) {
|
|
73
|
+
throw new Error(`Invalid stored conversation display envelope at ${identity}: ${header.error.message}`);
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
const messages = await validateUIMessages({
|
|
77
|
+
messages: header.data.messages,
|
|
78
|
+
dataSchemas,
|
|
79
|
+
metadataSchema: z.object({ interrupted: z.boolean().optional() }).optional(),
|
|
80
|
+
});
|
|
81
|
+
return { ...header.data, messages };
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
85
|
+
throw new Error(`Invalid stored conversation display envelope at ${identity}: ${message}`, { cause: error });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/** The data-part key a Cortex part is stored under — its `type` minus the `data-` prefix. */
|
|
89
|
+
function displayPartType(part) {
|
|
90
|
+
return (part.type === "tool-call" ? "data-tool-call" : part.type);
|
|
91
|
+
}
|
|
92
|
+
/** The id a part reconciles on: a tool call by its call id, a card by its own stable id. */
|
|
93
|
+
function displayPartId(part) {
|
|
94
|
+
if (part.type === "tool-call")
|
|
95
|
+
return part.toolCallId;
|
|
96
|
+
return "id" in part && typeof part.id === "string" ? part.id : undefined;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Build the stored form of one non-text Cortex part.
|
|
100
|
+
*
|
|
101
|
+
* A tool call is the one part whose stored payload is not simply the Cortex part
|
|
102
|
+
* minus its discriminant: `outcome` is optional on the live part (absent means in
|
|
103
|
+
* flight) and required here, because a record is of a call that is no longer
|
|
104
|
+
* running. An in-flight call being recorded is one the turn never saw finish, so
|
|
105
|
+
* absence resolves to `incomplete` at exactly this boundary — once, where the
|
|
106
|
+
* lifetime changes — rather than at each reader.
|
|
107
|
+
*/
|
|
108
|
+
export function conversationDisplayPart(part) {
|
|
109
|
+
const { type: _type, ...rest } = part;
|
|
110
|
+
const data = part.type === "tool-call" ? { ...rest, outcome: part.outcome ?? "incomplete" } : rest;
|
|
111
|
+
return { type: displayPartType(part), id: displayPartId(part), data };
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Project Cortex display messages into the durable representation.
|
|
115
|
+
*
|
|
116
|
+
* The startup backfill's only path into storage: it renders a legacy turn from
|
|
117
|
+
* the model transcript and freezes the result. The live recorder builds the same
|
|
118
|
+
* shape directly from the event stream instead of round-tripping through
|
|
119
|
+
* `CortexMessage`.
|
|
120
|
+
*/
|
|
121
|
+
export function cortexMessagesToConversationUI(messages) {
|
|
122
|
+
return messages.map((message) => {
|
|
123
|
+
const parts = message.parts.map((part) => part.type === "text" ? { type: "text", text: part.text, state: "done" } : conversationDisplayPart(part));
|
|
124
|
+
return {
|
|
125
|
+
id: message.id,
|
|
126
|
+
role: message.role,
|
|
127
|
+
...(message.interrupted ? { metadata: { interrupted: true } } : {}),
|
|
128
|
+
parts,
|
|
129
|
+
};
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Read the durable representation back as Cortex display messages.
|
|
134
|
+
*
|
|
135
|
+
* Every part round-trips by moving its discriminant back out of the part type,
|
|
136
|
+
* so nothing here consults a tool name, a registry, or the filesystem. A message
|
|
137
|
+
* whose parts all render empty is dropped so the transcript has no empty bubbles.
|
|
138
|
+
*/
|
|
139
|
+
export function conversationUIToCortexMessages(messages) {
|
|
140
|
+
const out = [];
|
|
141
|
+
for (const message of messages) {
|
|
142
|
+
const parts = [];
|
|
143
|
+
for (const part of message.parts) {
|
|
144
|
+
if (part.type === "text") {
|
|
145
|
+
if (part.text.length > 0)
|
|
146
|
+
parts.push({ type: "text", text: part.text });
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (part.type === "data-tool-call") {
|
|
150
|
+
parts.push({ type: "tool-call", ...part.data });
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
if (part.type.startsWith("data-")) {
|
|
154
|
+
const data = part;
|
|
155
|
+
parts.push({ type: data.type, ...data.data });
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if (parts.length === 0)
|
|
159
|
+
continue;
|
|
160
|
+
out.push({
|
|
161
|
+
id: message.id,
|
|
162
|
+
role: message.role,
|
|
163
|
+
parts,
|
|
164
|
+
...(message.metadata?.interrupted ? { interrupted: true } : {}),
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
return out;
|
|
168
|
+
}
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* @deprecated Compatibility-only reconstruction for pre-display-envelope
|
|
3
|
+
* turns. New runtime reads use stored AI SDK UI messages. Retain this module
|
|
4
|
+
* through the pre-v1 direct-upgrade window, then remove its runtime fallback
|
|
5
|
+
* in a dedicated compatibility change.
|
|
3
6
|
*
|
|
4
7
|
* `show_plan` / `show_user` / `show_file` / `execute_analysis` / `iterate_report` emit
|
|
5
8
|
* `data-plan` / `data-presentation` / `data-file-reference` / `data-run-card` /
|
|
@@ -13,11 +16,14 @@
|
|
|
13
16
|
*/
|
|
14
17
|
import type { CortexPart } from "@inflexa-ai/harness/contracts/message.js";
|
|
15
18
|
import type { Pool } from "pg";
|
|
19
|
+
/** @deprecated Used only by the legacy display backfill/fallback. */
|
|
16
20
|
export interface StoredToolCallForCard {
|
|
17
21
|
readonly type: "tool_use";
|
|
18
22
|
readonly id: string;
|
|
19
23
|
readonly name: string;
|
|
20
24
|
readonly input: unknown;
|
|
21
25
|
}
|
|
26
|
+
/** @deprecated Used only by the legacy display backfill/fallback. */
|
|
22
27
|
export type ToolCardResolver = (block: StoredToolCallForCard) => Promise<CortexPart | null>;
|
|
28
|
+
/** @deprecated Use persisted conversation display envelopes for runtime reads. */
|
|
23
29
|
export declare function createCardResolver(pool: Pool, analysisId: string, workspaceRoot: string): ToolCardResolver;
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* @deprecated Compatibility-only reconstruction for pre-display-envelope
|
|
3
|
+
* turns. New runtime reads use stored AI SDK UI messages. Retain this module
|
|
4
|
+
* through the pre-v1 direct-upgrade window, then remove its runtime fallback
|
|
5
|
+
* in a dedicated compatibility change.
|
|
3
6
|
*
|
|
4
7
|
* `show_plan` / `show_user` / `show_file` / `execute_analysis` / `iterate_report` emit
|
|
5
8
|
* `data-plan` / `data-presentation` / `data-file-reference` / `data-run-card` /
|
|
@@ -15,6 +18,7 @@ import { unwrapOrThrow } from "../lib/result.js";
|
|
|
15
18
|
import { adHocPlanId, adHocRunId } from "../tools/analysis-invocation.js";
|
|
16
19
|
import { validatePath } from "../tools/lib/path-validation.js";
|
|
17
20
|
import { buildFileReferenceCardData, buildPlanCardData, buildPresentationCardData, buildPreviewCardData, buildRunCardData } from "./card-builders.js";
|
|
21
|
+
/** @deprecated Use persisted conversation display envelopes for runtime reads. */
|
|
18
22
|
export function createCardResolver(pool, analysisId, workspaceRoot) {
|
|
19
23
|
return async (block) => {
|
|
20
24
|
if (block.type !== "tool_use")
|
|
@@ -26,11 +26,18 @@ import type { Pool } from "pg";
|
|
|
26
26
|
import type { TokenUsageRollup } from "../contracts/usage.js";
|
|
27
27
|
import { type DbError } from "../lib/db-result.js";
|
|
28
28
|
import { type StoredMessageEnvelope } from "./ai-sdk-message-storage.js";
|
|
29
|
+
import { type ConversationUIMessage, type StoredDisplayEnvelope } from "./conversation-display-storage.js";
|
|
29
30
|
/** One stored message, as returned by the display read (`loadPage`). */
|
|
30
31
|
export interface StoredMessage {
|
|
31
32
|
readonly seq: number;
|
|
32
33
|
readonly envelope: StoredMessageEnvelope;
|
|
33
34
|
readonly message: ModelMessage;
|
|
35
|
+
/**
|
|
36
|
+
* The display projection of the append this row OPENED — present on the first
|
|
37
|
+
* row of each `appendTurn` and absent on every other row. A conversation turn
|
|
38
|
+
* and a host-appended record are both appends, so both carry one.
|
|
39
|
+
*/
|
|
40
|
+
readonly displayEnvelope?: StoredDisplayEnvelope;
|
|
34
41
|
/**
|
|
35
42
|
* What providers reported for the whole TURN this row completed — present
|
|
36
43
|
* only on the last assistant row of a turn appended with a rollup, absent
|
|
@@ -41,6 +48,41 @@ export interface StoredMessage {
|
|
|
41
48
|
*/
|
|
42
49
|
readonly usage?: TokenUsageRollup;
|
|
43
50
|
}
|
|
51
|
+
/**
|
|
52
|
+
* One atomic append: the exact provider-facing history, the display projection of
|
|
53
|
+
* what the user was shown, and what the turn reported spending.
|
|
54
|
+
*
|
|
55
|
+
* The two message projections are independent by design. `modelMessages` is what
|
|
56
|
+
* the provider sees and the only input to token accounting; `displayMessages` is
|
|
57
|
+
* what the transcript replays. Neither is derived from the other, and a failed
|
|
58
|
+
* write commits neither.
|
|
59
|
+
*/
|
|
60
|
+
export interface ConversationTurn {
|
|
61
|
+
/** Exact provider-facing history; token accounting and model reads use only this projection. */
|
|
62
|
+
readonly modelMessages: readonly ModelMessage[];
|
|
63
|
+
/** Complete ordered AI SDK UI messages recorded from the live display event stream. */
|
|
64
|
+
readonly displayMessages: readonly ConversationUIMessage[];
|
|
65
|
+
/**
|
|
66
|
+
* The turn's reported rollup, as `AgentFinish.turnUsage` carries it out of the
|
|
67
|
+
* loop. Stored on the LAST assistant message of the turn — the row a reader
|
|
68
|
+
* associates with the reply. Omitting it, and supplying one that reports no
|
|
69
|
+
* quantity at all, both leave the row without one: a turn that reported nothing
|
|
70
|
+
* must not read back as a turn that cost zero.
|
|
71
|
+
*/
|
|
72
|
+
readonly turnUsage?: TokenUsageRollup;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* The append a host uses to record out-of-band work in a thread — an analysis
|
|
76
|
+
* run's outcome, typically.
|
|
77
|
+
*
|
|
78
|
+
* Both projections are built here because a record has no live turn behind it:
|
|
79
|
+
* no recorder observed it, so nothing else would produce its display projection,
|
|
80
|
+
* and a record appended without one would be stored, read by the model, and
|
|
81
|
+
* never shown. The harness owns the record-to-`system` mapping for the same
|
|
82
|
+
* reason it owns the marker — a host that hand-assembled either could produce a
|
|
83
|
+
* message the turn-boundary predicates fail to recognise.
|
|
84
|
+
*/
|
|
85
|
+
export declare function conversationRecordTurn(text: string): ConversationTurn;
|
|
44
86
|
/**
|
|
45
87
|
* A page of stored messages plus pagination metadata. Pagination is
|
|
46
88
|
* turn-based: `total` is the thread's turn count and `page`/`perPage` index
|
|
@@ -74,20 +116,13 @@ export type RetractOutcome = {
|
|
|
74
116
|
*/
|
|
75
117
|
export interface ThreadHistory {
|
|
76
118
|
/**
|
|
77
|
-
* Append one
|
|
119
|
+
* Append one {@link ConversationTurn} — every message written in a single
|
|
78
120
|
* transaction with a `seq` monotonically increasing per thread.
|
|
79
121
|
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
* surface. It is stored on the LAST assistant message of the turn — the row a
|
|
83
|
-
* reader associates with the reply — so a reloaded transcript renders the
|
|
84
|
-
* figure the live turn showed. Optional at every layer: omitting it, and
|
|
85
|
-
* supplying one that reports no quantity at all, both leave the row without
|
|
86
|
-
* one, because a turn that reported nothing must not read back as a turn that
|
|
87
|
-
* cost zero. A turn writing no assistant message stores none and still
|
|
88
|
-
* succeeds — there is no row on which the figure would mean anything.
|
|
122
|
+
* A turn writing no assistant message stores no rollup and still succeeds:
|
|
123
|
+
* there is no row on which the figure would mean anything.
|
|
89
124
|
*/
|
|
90
|
-
appendTurn(threadId: string,
|
|
125
|
+
appendTurn(threadId: string, turn: ConversationTurn): ResultAsync<void, DbError>;
|
|
91
126
|
/**
|
|
92
127
|
* Return a recent-turns window that fits `tokenBudget`, oldest-first,
|
|
93
128
|
* snapped to a valid AI SDK model-message sequence. The window START advances
|
|
@@ -20,17 +20,36 @@
|
|
|
20
20
|
* is the atomic unit; `appendTurn` writes one atomically and `loadRecent`
|
|
21
21
|
* rounds the budget walk to turn boundaries.
|
|
22
22
|
*/
|
|
23
|
+
import { randomUUID } from "node:crypto";
|
|
23
24
|
import { metrics } from "@opentelemetry/api";
|
|
24
25
|
import { ResultAsync, ok, okAsync } from "neverthrow";
|
|
25
26
|
import { stripNulCharacters } from "../input-sanitization.js";
|
|
26
27
|
import { tryMutation, tryQuery, withTransaction } from "../lib/db-result.js";
|
|
27
28
|
import { hasReportedUsage } from "../loop/metrics.js";
|
|
28
29
|
import { countTokens } from "./count-tokens.js";
|
|
29
|
-
import { HARNESS_PROVIDER_NAMESPACE, SYNTHETIC_MESSAGE_KEY, envelopeMessage, isSyntheticUserMessage, parseStoredMessageEnvelope, } from "./ai-sdk-message-storage.js";
|
|
30
|
+
import { HARNESS_PROVIDER_NAMESPACE, SYNTHETIC_MESSAGE_KEY, envelopeMessage, isSyntheticUserMessage, parseStoredMessageEnvelope, syntheticRecordMessage, } from "./ai-sdk-message-storage.js";
|
|
31
|
+
import { envelopeDisplayMessages, parseStoredDisplayEnvelope } from "./conversation-display-storage.js";
|
|
30
32
|
/** A resolved `ok(undefined)` ResultAsync — the empty/seed transaction step. */
|
|
31
33
|
function okVoid() {
|
|
32
34
|
return new ResultAsync(Promise.resolve(ok(undefined)));
|
|
33
35
|
}
|
|
36
|
+
/**
|
|
37
|
+
* The append a host uses to record out-of-band work in a thread — an analysis
|
|
38
|
+
* run's outcome, typically.
|
|
39
|
+
*
|
|
40
|
+
* Both projections are built here because a record has no live turn behind it:
|
|
41
|
+
* no recorder observed it, so nothing else would produce its display projection,
|
|
42
|
+
* and a record appended without one would be stored, read by the model, and
|
|
43
|
+
* never shown. The harness owns the record-to-`system` mapping for the same
|
|
44
|
+
* reason it owns the marker — a host that hand-assembled either could produce a
|
|
45
|
+
* message the turn-boundary predicates fail to recognise.
|
|
46
|
+
*/
|
|
47
|
+
export function conversationRecordTurn(text) {
|
|
48
|
+
return {
|
|
49
|
+
modelMessages: [syntheticRecordMessage(text)],
|
|
50
|
+
displayMessages: [{ id: randomUUID(), role: "system", parts: [{ type: "text", text, state: "done" }] }],
|
|
51
|
+
};
|
|
52
|
+
}
|
|
34
53
|
/**
|
|
35
54
|
* A turn starts on a `user` message that a human actually sent. Two kinds of
|
|
36
55
|
* message carry the `user` role without opening a turn, and both are excluded
|
|
@@ -156,9 +175,16 @@ export const EVICTION_BLOCK_TURNS = 4;
|
|
|
156
175
|
* provisioned by the project's state-init DDL.
|
|
157
176
|
*/
|
|
158
177
|
export function createThreadHistory(pool) {
|
|
159
|
-
function appendTurn(threadId,
|
|
178
|
+
function appendTurn(threadId, turn) {
|
|
179
|
+
const { modelMessages: messages, displayMessages, turnUsage } = turn;
|
|
160
180
|
if (messages.length === 0)
|
|
161
181
|
return okVoid();
|
|
182
|
+
// The display projection rides the append's FIRST row, so one SELECT of a
|
|
183
|
+
// thread's rows yields every envelope in order with no join and no grouping.
|
|
184
|
+
// Whichever row that is — a genuine user message opening a turn, or the lone
|
|
185
|
+
// record row of an out-of-band append — is the row a tail retraction removes
|
|
186
|
+
// the envelope with, because it is the row the projection describes.
|
|
187
|
+
const displayEnvelope = displayMessages.length > 0 ? JSON.stringify(envelopeDisplayMessages(displayMessages)) : null;
|
|
162
188
|
// A rollup that reports no quantity is stored as absent, not as a rollup of
|
|
163
189
|
// absences, so "no figure" has exactly one representation in storage.
|
|
164
190
|
// `hasReportedUsage` is the loop's own predicate for the same question,
|
|
@@ -185,14 +211,23 @@ export function createThreadHistory(pool) {
|
|
|
185
211
|
// re-throws it to force ROLLBACK (a returned `err` that does not
|
|
186
212
|
// reach `withTransaction` would COMMIT silently).
|
|
187
213
|
messages.reduce((chain, message, i) => chain.andThen(() => tryMutation("thread-history.appendTurn.insert", () => client.query(
|
|
188
|
-
// `message_envelope::json`, never `::jsonb
|
|
189
|
-
// never `::json` — see the column comments
|
|
190
|
-
|
|
191
|
-
|
|
214
|
+
// `message_envelope::json`, never `::jsonb`; `display_envelope` and
|
|
215
|
+
// `reported_usage::jsonb`, never `::json` — see the column comments
|
|
216
|
+
// in the state-init DDL.
|
|
217
|
+
`INSERT INTO messages (thread_id, seq, message_envelope, display_envelope, tokens, reported_usage)
|
|
218
|
+
VALUES ($1, $2, $3::json, $4::jsonb, $5, $6::jsonb)
|
|
192
219
|
ON CONFLICT (thread_id, seq) DO UPDATE
|
|
193
220
|
SET message_envelope = EXCLUDED.message_envelope,
|
|
221
|
+
display_envelope = EXCLUDED.display_envelope,
|
|
194
222
|
tokens = EXCLUDED.tokens,
|
|
195
|
-
reported_usage = EXCLUDED.reported_usage`, [
|
|
223
|
+
reported_usage = EXCLUDED.reported_usage`, [
|
|
224
|
+
threadId,
|
|
225
|
+
startSeq + i,
|
|
226
|
+
serializeEnvelope(message),
|
|
227
|
+
i === 0 ? displayEnvelope : null,
|
|
228
|
+
countTokens(message.content),
|
|
229
|
+
i === rollupRow ? rollup : null,
|
|
230
|
+
])).map(() => undefined)), okVoid()))
|
|
196
231
|
.andThen(() =>
|
|
197
232
|
// Thread listings sort on `cortex_analysis_threads.updated_at`, and
|
|
198
233
|
// the only other writer of it is the title update — so without this
|
|
@@ -311,15 +346,22 @@ export function createThreadHistory(pool) {
|
|
|
311
346
|
// `seq::text AS seq` output alias (Postgres resolves an unqualified
|
|
312
347
|
// ORDER BY name to the output column), sorting the bigint as text:
|
|
313
348
|
// "10" before "2". The qualified name forces the bigint column.
|
|
314
|
-
`SELECT seq::text AS seq, message_envelope, reported_usage
|
|
349
|
+
`SELECT seq::text AS seq, message_envelope, display_envelope, reported_usage
|
|
315
350
|
FROM messages WHERE thread_id = $1
|
|
316
351
|
ORDER BY messages.seq ASC`, [threadId]);
|
|
317
|
-
|
|
352
|
+
// Spread rather than `usage: r.reported_usage ?? undefined`, so a row with
|
|
353
|
+
// no rollup carries no `usage` KEY at all — absent, not present-and-undefined.
|
|
354
|
+
const stored = await Promise.all(rows.map(async (r) => {
|
|
318
355
|
const envelope = parseStoredMessageEnvelope(r.message_envelope, `${threadId}/${r.seq}`);
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
356
|
+
const displayEnvelope = r.display_envelope == null ? undefined : await parseStoredDisplayEnvelope(r.display_envelope, `${threadId}/${r.seq}/display`);
|
|
357
|
+
return {
|
|
358
|
+
seq: Number(r.seq),
|
|
359
|
+
envelope,
|
|
360
|
+
message: envelope.message,
|
|
361
|
+
...(displayEnvelope ? { displayEnvelope } : {}),
|
|
362
|
+
...(r.reported_usage === null ? {} : { usage: r.reported_usage }),
|
|
363
|
+
};
|
|
364
|
+
}));
|
|
323
365
|
const turns = groupTurns(stored, (row) => isGenuineUserStart(row.message));
|
|
324
366
|
const total = turns.length;
|
|
325
367
|
const offset = safePage * safePerPage;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const bulkTranscriptomicsAgentPrompt = "# Bulk Transcriptomics Agent\n\nYou are a bulk RNA-seq and microarray analysis specialist. You
|
|
1
|
+
export declare const bulkTranscriptomicsAgentPrompt = "# Bulk Transcriptomics Agent\n\nYou are a bulk RNA-seq and microarray analysis specialist. You work from\na **count or expression matrix**: QC, filtering, normalization, batch\ncorrection, differential expression, cell-type deconvolution, and result\nvisualization. Quantification from reads is upstream work and out of\nscope \u2014 no aligner or quantifier is installed. If you are handed FASTQ,\nsay you need the count matrix and stop. You select the statistically\nappropriate method for each dataset based on data type, sample size, and\nexperimental design \u2014 and you justify that choice.\n\n## Skills\n\nYour skills: `bulk-transcriptomics`, `shared/omics-general`.\n\nAPI references in `bulk-transcriptomics`: PyDESeq2, DESeq2 via rpy2, edgeR,\nlimma/voom, sva. Check contrast syntax there before writing it.\n\n## Method Selection (Summary)\n\n- **Raw counts, simple design, n=3-50 per group** \u2014 PyDESeq2 (default).\n- **Raw counts, complex design (interactions, >2 factors)** \u2014 DESeq2 via rpy2.\n- **Raw counts, n > 50 per group** \u2014 limma-voom via rpy2 (scales better).\n- **Raw counts, n < 3 per group** \u2014 edgeR QLF via rpy2.\n- **Pre-normalized (TPM, FPKM, log-CPM, microarray intensities)** \u2014 limma\n via rpy2.\n- **Raw microarray CEL** \u2014 out of scope: reading them needs a per-design\n platform annotation package that cannot be staged or installed. Say so\n and ask for the normalized matrix.\n- **Batch effects** \u2014 ComBat_seq on raw counts (include biological\n covariates), or svaseq surrogate variables as model covariates.\n Correct after filtering, before DE.\n- **Longitudinal / repeated measures** \u2014 dream via rpy2.\n- **Cell-type deconvolution** \u2014 xCell2 via rpy2.\n\n## Domain Standards\n\n- Store results in AnnData: samples in `.obs`, genes in `.var`,\n counts/expression in `.X`, raw counts in `.layers[\"counts\"]` when\n normalization is applied.\n- Gene filter: keep genes with >= 10 counts in >= n samples where n =\n smallest group size.\n- Each DE method normalizes internally \u2014 do NOT pre-normalize before\n DESeq2/edgeR/voom.\n- Always inspect library sizes and PCA by batch before DE.\n\n## Required Figures (DE analysis)\n\n- **PCA** \u2014 sample-level, top 500 variable genes. Color by condition,\n shape by batch if present. The single most important QC figure.\n- **Sample distance heatmap** \u2014 Euclidean distances with hierarchical\n clustering, annotated by condition and batch.\n- **Volcano plot** \u2014 log2FC vs -log10(padj). Label top genes. Threshold\n lines.\n- **MA plot** \u2014 baseMean vs log2FC.\n- **Top gene heatmap** \u2014 top 50 DE genes, z-scored, with column\n annotation.\n\nFor deconvolution, include cell-type proportion bar plots per sample.\n\n## Domain Anti-Patterns\n\n- Attempting to quantify from reads, or hand-rolling a pseudoaligner.\n The count matrix is an input; report what you need instead.\n- DESeq2, PyDESeq2, or edgeR on TPM/FPKM/RPKM \u2014 these model raw counts.\n- voom on already-normalized or log-transformed data.\n- ComBat_seq without biological covariates in the model.\n- Using svaseq-adjusted counts directly \u2014 add SVs as model covariates.\n- Arbitrary gene filters \u2014 base thresholds on smallest group size.\n- Skipping batch assessment \u2014 always check PCA colored by batch.\n\n## Required Output Files\n\n- DE results CSV: columns `gene`, `log2_fold_change`, `pvalue`,\n `adjusted_pvalue`, `base_mean`. One file per contrast.\n- Normalized counts: AnnData `.h5ad` (and CSV for cross-agent use).\n";
|
|
@@ -1,29 +1,32 @@
|
|
|
1
1
|
export const bulkTranscriptomicsAgentPrompt = `# Bulk Transcriptomics Agent
|
|
2
2
|
|
|
3
|
-
You are a bulk RNA-seq and microarray analysis specialist. You
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
3
|
+
You are a bulk RNA-seq and microarray analysis specialist. You work from
|
|
4
|
+
a **count or expression matrix**: QC, filtering, normalization, batch
|
|
5
|
+
correction, differential expression, cell-type deconvolution, and result
|
|
6
|
+
visualization. Quantification from reads is upstream work and out of
|
|
7
|
+
scope — no aligner or quantifier is installed. If you are handed FASTQ,
|
|
8
|
+
say you need the count matrix and stop. You select the statistically
|
|
9
|
+
appropriate method for each dataset based on data type, sample size, and
|
|
10
|
+
experimental design — and you justify that choice.
|
|
9
11
|
|
|
10
12
|
## Skills
|
|
11
13
|
|
|
12
14
|
Your skills: \`bulk-transcriptomics\`, \`shared/omics-general\`.
|
|
13
15
|
|
|
14
16
|
API references in \`bulk-transcriptomics\`: PyDESeq2, DESeq2 via rpy2, edgeR,
|
|
15
|
-
limma/voom, sva
|
|
17
|
+
limma/voom, sva. Check contrast syntax there before writing it.
|
|
16
18
|
|
|
17
19
|
## Method Selection (Summary)
|
|
18
20
|
|
|
19
|
-
- **FASTQ** — STAR (splice-aware) or salmon/kallisto (pseudoalignment).
|
|
20
|
-
featureCounts for gene-level counts. fastqc + multiqc for read QC.
|
|
21
21
|
- **Raw counts, simple design, n=3-50 per group** — PyDESeq2 (default).
|
|
22
22
|
- **Raw counts, complex design (interactions, >2 factors)** — DESeq2 via rpy2.
|
|
23
23
|
- **Raw counts, n > 50 per group** — limma-voom via rpy2 (scales better).
|
|
24
24
|
- **Raw counts, n < 3 per group** — edgeR QLF via rpy2.
|
|
25
|
-
- **Pre-normalized (TPM, FPKM, log-CPM)** — limma
|
|
26
|
-
|
|
25
|
+
- **Pre-normalized (TPM, FPKM, log-CPM, microarray intensities)** — limma
|
|
26
|
+
via rpy2.
|
|
27
|
+
- **Raw microarray CEL** — out of scope: reading them needs a per-design
|
|
28
|
+
platform annotation package that cannot be staged or installed. Say so
|
|
29
|
+
and ask for the normalized matrix.
|
|
27
30
|
- **Batch effects** — ComBat_seq on raw counts (include biological
|
|
28
31
|
covariates), or svaseq surrogate variables as model covariates.
|
|
29
32
|
Correct after filtering, before DE.
|
|
@@ -53,11 +56,12 @@ limma/voom, sva, oligo/affy. Check contrast syntax there before writing it.
|
|
|
53
56
|
- **Top gene heatmap** — top 50 DE genes, z-scored, with column
|
|
54
57
|
annotation.
|
|
55
58
|
|
|
56
|
-
For
|
|
57
|
-
deconvolution, include cell-type proportion bar plots per sample.
|
|
59
|
+
For deconvolution, include cell-type proportion bar plots per sample.
|
|
58
60
|
|
|
59
61
|
## Domain Anti-Patterns
|
|
60
62
|
|
|
63
|
+
- Attempting to quantify from reads, or hand-rolling a pseudoaligner.
|
|
64
|
+
The count matrix is an input; report what you need instead.
|
|
61
65
|
- DESeq2, PyDESeq2, or edgeR on TPM/FPKM/RPKM — these model raw counts.
|
|
62
66
|
- voom on already-normalized or log-transformed data.
|
|
63
67
|
- ComBat_seq without biological covariates in the model.
|