@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
|
@@ -16,7 +16,11 @@ export const meta = {
|
|
|
16
16
|
"preclinical target intelligence (KO + expression)",
|
|
17
17
|
],
|
|
18
18
|
suitableFor: ["drug-repurposing", "drug-repositioning", "indication-expansion", "target-drug-mapping", "therapeutic-candidates", "perturbation-signatures"],
|
|
19
|
-
|
|
19
|
+
// `genomic-variants` carries the Mendelian randomization reference. The
|
|
20
|
+
// genetics-based repurposing strategy turns on whether a target's disease
|
|
21
|
+
// association is causal, and MR is the test — without this the pack points at
|
|
22
|
+
// a reference the agent cannot read.
|
|
23
|
+
skills: ["drug-repurposing", "cheminformatics", "genomic-variants", "shared/omics-general"],
|
|
20
24
|
tools: [
|
|
21
25
|
...BASE_SANDBOX_TOOLS,
|
|
22
26
|
"targetSafety",
|
|
@@ -23,7 +23,10 @@ export const meta = {
|
|
|
23
23
|
"tcr-bcr-data",
|
|
24
24
|
"immune-cell-composition",
|
|
25
25
|
],
|
|
26
|
-
|
|
26
|
+
// `single-cell` carries the scirpy reference, which is the route from 10x
|
|
27
|
+
// VDJ / AIRR files to called clonotypes — the input every repertoire metric
|
|
28
|
+
// in `immune-profiling` assumes it already has.
|
|
29
|
+
skills: ["immune-profiling", "single-cell", "shared/omics-general"],
|
|
27
30
|
tools: [...BASE_SANDBOX_TOOLS, "pubmed", "searchGene", "lookupAnnotation", "searchInteractions", "searchGeoDatasets", "opentargets"],
|
|
28
31
|
};
|
|
29
32
|
export function createImmuneProfilingAgent(deps) {
|
|
@@ -37,6 +37,22 @@ export interface TextDeltaEvent {
|
|
|
37
37
|
* admit the impossible "not an error, but denied".
|
|
38
38
|
*/
|
|
39
39
|
export type ToolOutcome = "ok" | "error" | "denied";
|
|
40
|
+
/**
|
|
41
|
+
* How a RECORDED call ended — the three live outcomes plus `incomplete`.
|
|
42
|
+
*
|
|
43
|
+
* `incomplete` is the state a live event cannot carry and a record cannot avoid:
|
|
44
|
+
* the harness observed a dispatch and no completion, because the turn was cut off
|
|
45
|
+
* mid-call. A `tool-finished` is never incomplete, which is why this widens
|
|
46
|
+
* {@link ToolOutcome} rather than replacing it.
|
|
47
|
+
*
|
|
48
|
+
* It is a fourth value of ONE field rather than a second field beside the outcome,
|
|
49
|
+
* for the same reason the outcome is three-way instead of `isError` plus `denied`:
|
|
50
|
+
* a call has exactly one terminal state, and splitting that across two fields both
|
|
51
|
+
* admits impossible pairs and leaves every consumer to invent the meaning of the
|
|
52
|
+
* combination. A consumer switches on this one field, and a consumer that forgets
|
|
53
|
+
* a case fails to compile rather than silently reporting a success.
|
|
54
|
+
*/
|
|
55
|
+
export type ToolCallOutcome = ToolOutcome | "incomplete";
|
|
40
56
|
/**
|
|
41
57
|
* One line naming what a tool call is doing, produced by the tool's own
|
|
42
58
|
* `describeCall` hook and normalized at the emit site.
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* discriminant is `type`, so `switch (part.type) { case "data-report-preview": ... }`
|
|
7
7
|
* narrows the union member without any `as` cast.
|
|
8
8
|
*/
|
|
9
|
-
import type { ToolCallDetail,
|
|
9
|
+
import type { ToolCallDetail, ToolCallOutcome } from "./chat-events.js";
|
|
10
10
|
import type { CortexChatPart } from "./chat-parts.js";
|
|
11
11
|
import type { TokenUsageRollup } from "./usage.js";
|
|
12
12
|
/** A plain assistant/user text run. */
|
|
@@ -17,20 +17,25 @@ export interface TextPart {
|
|
|
17
17
|
/**
|
|
18
18
|
* A tool call observed during the turn (or replayed from history).
|
|
19
19
|
*
|
|
20
|
-
* Live frames collapse a `tool-started`+`tool-finished` pair into a single
|
|
21
|
-
*
|
|
22
|
-
*
|
|
20
|
+
* Live frames collapse a `tool-started`+`tool-finished` pair into a single part
|
|
21
|
+
* keyed by `toolCallId`.
|
|
22
|
+
*
|
|
23
|
+
* `outcome` carries the call's whole terminal state in one field, and its absence
|
|
24
|
+
* means exactly one thing: the call is still in flight. A REPLAYED call therefore
|
|
25
|
+
* always has one — a turn cut off mid-call replays as `incomplete`, not as an
|
|
26
|
+
* absent outcome a reader would have to interpret. That is what lets a consumer
|
|
27
|
+
* switch on this single field and be told by the compiler when it misses a case,
|
|
28
|
+
* rather than each host inventing what "no outcome" means and disagreeing.
|
|
23
29
|
*/
|
|
24
30
|
export interface ToolCallPart {
|
|
25
31
|
type: "tool-call";
|
|
26
32
|
toolCallId: string;
|
|
27
33
|
toolName: string;
|
|
28
|
-
status: "started" | "finished";
|
|
29
34
|
/**
|
|
30
|
-
* How the call ended. Absent while
|
|
31
|
-
*
|
|
35
|
+
* How the call ended. Absent ONLY while the call is in flight on a live
|
|
36
|
+
* surface; every replayed call carries one. See {@link ToolCallOutcome}.
|
|
32
37
|
*/
|
|
33
|
-
outcome?:
|
|
38
|
+
outcome?: ToolCallOutcome;
|
|
34
39
|
/** See {@link ToolCallDetail}. Absent when no detail was produced. */
|
|
35
40
|
detail?: ToolCallDetail;
|
|
36
41
|
}
|
|
@@ -20,6 +20,13 @@ export declare const ToolOutcomeSchema: z.ZodEnum<{
|
|
|
20
20
|
ok: "ok";
|
|
21
21
|
denied: "denied";
|
|
22
22
|
}>;
|
|
23
|
+
/** Mirrors `ToolCallOutcome` — the live three plus the record-only `incomplete`. */
|
|
24
|
+
export declare const ToolCallOutcomeSchema: z.ZodEnum<{
|
|
25
|
+
error: "error";
|
|
26
|
+
ok: "ok";
|
|
27
|
+
denied: "denied";
|
|
28
|
+
incomplete: "incomplete";
|
|
29
|
+
}>;
|
|
23
30
|
export declare const ToolStartedEventSchema: z.ZodObject<{
|
|
24
31
|
type: z.ZodLiteral<"tool-started">;
|
|
25
32
|
toolUseId: z.ZodString;
|
|
@@ -14,6 +14,8 @@ export const TextDeltaEventSchema = z.object({
|
|
|
14
14
|
});
|
|
15
15
|
/** Mirrors the `ToolOutcome` union — three states, never a boolean. */
|
|
16
16
|
export const ToolOutcomeSchema = z.enum(["ok", "error", "denied"]);
|
|
17
|
+
/** Mirrors `ToolCallOutcome` — the live three plus the record-only `incomplete`. */
|
|
18
|
+
export const ToolCallOutcomeSchema = z.enum(["ok", "error", "denied", "incomplete"]);
|
|
17
19
|
export const ToolStartedEventSchema = z.object({
|
|
18
20
|
type: z.literal("tool-started"),
|
|
19
21
|
toolUseId: z.string(),
|
package/dist/index.d.ts
CHANGED
|
@@ -57,16 +57,15 @@ export { prepareChatTurn } from "./app/chat-turn.js";
|
|
|
57
57
|
export type { PrepareChatTurnDeps, PrepareChatTurnParams, PrepareChatTurnResult } from "./app/chat-turn.js";
|
|
58
58
|
export { createThreadStore } from "./memory/thread-store.js";
|
|
59
59
|
export type { ThreadStore, Thread, CreateThreadInput, ListThreadsInput, ThreadPage } from "./memory/thread-store.js";
|
|
60
|
-
export { createThreadHistory } from "./memory/thread-history.js";
|
|
61
|
-
export type { ThreadHistory, StoredMessage, MessagePage, RetractOutcome } from "./memory/thread-history.js";
|
|
60
|
+
export { createThreadHistory, conversationRecordTurn } from "./memory/thread-history.js";
|
|
61
|
+
export type { ThreadHistory, StoredMessage, MessagePage, RetractOutcome, ConversationTurn } from "./memory/thread-history.js";
|
|
62
62
|
export { syntheticUserMessage, syntheticRecordMessage, isSyntheticUserMessage, isSyntheticRecordMessage } from "./memory/ai-sdk-message-storage.js";
|
|
63
|
-
export {
|
|
64
|
-
export type {
|
|
65
|
-
export {
|
|
66
|
-
export type {
|
|
67
|
-
export {
|
|
68
|
-
export type {
|
|
69
|
-
export type { CortexChatEvent, TextDeltaEvent, ToolStartedEvent, ToolFinishedEvent, ToolOutcome, ToolCallDetail, FinishEvent, ChatErrorEvent, } from "./contracts/chat-events.js";
|
|
63
|
+
export { createConversationDisplayRecorder } from "./memory/conversation-display-recorder.js";
|
|
64
|
+
export type { ConversationDisplayRecorder, ConversationDisplayRecorderOptions } from "./memory/conversation-display-recorder.js";
|
|
65
|
+
export { envelopeDisplayMessages, parseStoredDisplayEnvelope } from "./memory/conversation-display-storage.js";
|
|
66
|
+
export type { ConversationDisplayMetadata, ConversationUIData, ConversationUIMessage, StoredDisplayEnvelope } from "./memory/conversation-display-storage.js";
|
|
67
|
+
export { storedMessagesToCortex } from "./memory/conversation-display-replay.js";
|
|
68
|
+
export type { CortexChatEvent, TextDeltaEvent, ToolStartedEvent, ToolFinishedEvent, ToolOutcome, ToolCallOutcome, ToolCallDetail, FinishEvent, ChatErrorEvent, } from "./contracts/chat-events.js";
|
|
70
69
|
export type { TokenUsageRollup } from "./contracts/usage.js";
|
|
71
70
|
export type { CortexChatPart, PresentationPart, PresentationContent, PlanPart, RunCardPart, FileReferencePart, FileReferenceEntry, RunStartedPart, DagStatePart, DagStepState, StepStatus, StepActivityPart, StepPhase, StepFileTreePart, FileTreeEntry, StepOutputPart, StepOutputFile, StepSummaryPart, StepUsagePart, StepBlockedPart, RunSynthesisPart, SynthesizedFinding, BiologicalTheme, SynthesisProgressPart, SynthesisPhase, RunCompletedPart, RunCompletedFinding, RunFailedPart, PreviewPart, DataPreviewFailedPart, } from "./contracts/chat-parts.js";
|
|
72
71
|
export { PART_REGISTRY, isTransient, isReconciling, isSidebarPart } from "./contracts/part-registry.js";
|
package/dist/index.js
CHANGED
|
@@ -80,15 +80,13 @@ export { toProviderError, isProviderError } from "./providers/errors.js";
|
|
|
80
80
|
// gets live token streaming out of the otherwise non-streaming loop.
|
|
81
81
|
export { createStreamingChat } from "./providers/streaming-chat.js";
|
|
82
82
|
// Conversation turn + thread memory — the transport-free halves of one chat
|
|
83
|
-
// turn a same-process host drives itself. `prepareChatTurn` assembles the
|
|
84
|
-
// message array
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
// metadata (create/list/title). `contentToCortexMessages` + `createCardResolver`
|
|
88
|
-
// reconstruct display cards from a persisted thread on reload.
|
|
83
|
+
// turn a same-process host drives itself. `prepareChatTurn` assembles the model
|
|
84
|
+
// message array; `createConversationDisplayRecorder` records the independently
|
|
85
|
+
// replayable UI projection while forwarding live events; `appendTurn` persists
|
|
86
|
+
// both atomically. `createThreadStore` owns thread metadata (create/list/title).
|
|
89
87
|
export { prepareChatTurn } from "./app/chat-turn.js";
|
|
90
88
|
export { createThreadStore } from "./memory/thread-store.js";
|
|
91
|
-
export { createThreadHistory } from "./memory/thread-history.js";
|
|
89
|
+
export { createThreadHistory, conversationRecordTurn } from "./memory/thread-history.js";
|
|
92
90
|
// The synthetic-message primitives. A host appends a record of out-of-band work (an analysis run's
|
|
93
91
|
// outcome) into a thread through `appendTurn`, and needs a message the model reads but that is not
|
|
94
92
|
// user input — which is exactly what the marker denotes. The constructor is exported rather than
|
|
@@ -96,16 +94,19 @@ export { createThreadHistory } from "./memory/thread-history.js";
|
|
|
96
94
|
// (`isGenuineUserStart` and its SQL twin) would then fail to recognise; `isSyntheticUserMessage`
|
|
97
95
|
// lets a host renderer tell such a message apart from a genuine user turn on the way back out.
|
|
98
96
|
// `syntheticRecordMessage` is the one a HOST reaches for: equally non-turn-opening, but rendered —
|
|
99
|
-
//
|
|
100
|
-
//
|
|
97
|
+
// it displays as a `system` message rather than being dropped as loop machinery, so an appended run
|
|
98
|
+
// outcome is visible in the transcript instead of silently absent. `conversationRecordTurn` wraps it
|
|
99
|
+
// as a complete append (model message plus its display projection), which is what a host should
|
|
100
|
+
// reach for: a record appended without one would be stored but never replayed.
|
|
101
101
|
export { syntheticUserMessage, syntheticRecordMessage, isSyntheticUserMessage, isSyntheticRecordMessage } from "./memory/ai-sdk-message-storage.js";
|
|
102
|
-
export {
|
|
103
|
-
export {
|
|
104
|
-
// The
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
|
|
102
|
+
export { createConversationDisplayRecorder } from "./memory/conversation-display-recorder.js";
|
|
103
|
+
export { envelopeDisplayMessages, parseStoredDisplayEnvelope } from "./memory/conversation-display-storage.js";
|
|
104
|
+
// The transcript read. Every append persists what it displayed, so replay concatenates stored
|
|
105
|
+
// projections and consults nothing else — no tool names, no card builders, no workspace or database
|
|
106
|
+
// lookups. The migration renderer that DOES consult those (`content-to-cortex.js`,
|
|
107
|
+
// `reconstruct-cards.js`, `tools/detail-resolver.js`) is deliberately absent from this barrel: it
|
|
108
|
+
// runs once per legacy row at startup and has no runtime caller.
|
|
109
|
+
export { storedMessagesToCortex } from "./memory/conversation-display-replay.js";
|
|
109
110
|
export { PART_REGISTRY, isTransient, isReconciling, isSidebarPart } from "./contracts/part-registry.js";
|
|
110
111
|
// Embedder runtime surface — what a same-process host needs to run durable
|
|
111
112
|
// workflows itself: the DBOS lifecycle, per-workflow registration + triggers,
|
|
@@ -1,26 +1,27 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* The MIGRATION renderer: stored AI SDK model messages → `CortexMessage[]`.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* is
|
|
6
|
-
*
|
|
4
|
+
* This is how a conversation written before display projections were persisted
|
|
5
|
+
* is rendered ONCE, at startup, so it can be frozen into a display envelope
|
|
6
|
+
* (`conversation-display-backfill.ts`). It is not a runtime read path — the
|
|
7
|
+
* runtime reads the stored projection (`conversation-display-replay.ts`) and
|
|
8
|
+
* never consults a tool name, a card builder, or the filesystem.
|
|
9
|
+
*
|
|
10
|
+
* Everything it does is inference from a transcript that was never meant to
|
|
11
|
+
* carry display, which is why it belongs to the migration and nothing else:
|
|
7
12
|
*
|
|
8
13
|
* - text part → text part
|
|
9
14
|
* - tool-call part → a reconstructed display card (`data-plan`,
|
|
10
15
|
* `data-presentation`) when `resolveCard` recognises
|
|
11
|
-
* the tool, else a generic tool-call part
|
|
16
|
+
* the tool, else a generic tool-call part whose detail
|
|
17
|
+
* `resolveDetail` recomputes from the persisted input
|
|
12
18
|
* - everything else → dropped (reasoning, tool-result, etc.) — the UI
|
|
13
19
|
* does not render them
|
|
14
20
|
*
|
|
15
|
-
* Display cards are emitted live over the chat SSE stream but never persisted
|
|
16
|
-
* (storage holds only the AI SDK model-message transcript). `resolveCard`
|
|
17
|
-
* rebuilds them from the persisted tool-call part so they reappear on reload,
|
|
18
|
-
* and `resolveDetail` rebuilds each call's one-line detail the same way.
|
|
19
|
-
*
|
|
20
21
|
* A `tool-result` block is not rendered, but it is not ignored either: it is the
|
|
21
22
|
* only record of how its call ended. The blocks are indexed by `toolCallId`
|
|
22
23
|
* before conversion so each tool-call part can report its own outcome — without
|
|
23
|
-
* that pass every
|
|
24
|
+
* that pass every migrated call would report success, including the failed ones.
|
|
24
25
|
*
|
|
25
26
|
* A row that yields no renderable parts (e.g. a `tool`-role message carrying
|
|
26
27
|
* only tool-result continuation parts) is omitted entirely, so the rendered
|
|
@@ -1,26 +1,27 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* The MIGRATION renderer: stored AI SDK model messages → `CortexMessage[]`.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* is
|
|
6
|
-
*
|
|
4
|
+
* This is how a conversation written before display projections were persisted
|
|
5
|
+
* is rendered ONCE, at startup, so it can be frozen into a display envelope
|
|
6
|
+
* (`conversation-display-backfill.ts`). It is not a runtime read path — the
|
|
7
|
+
* runtime reads the stored projection (`conversation-display-replay.ts`) and
|
|
8
|
+
* never consults a tool name, a card builder, or the filesystem.
|
|
9
|
+
*
|
|
10
|
+
* Everything it does is inference from a transcript that was never meant to
|
|
11
|
+
* carry display, which is why it belongs to the migration and nothing else:
|
|
7
12
|
*
|
|
8
13
|
* - text part → text part
|
|
9
14
|
* - tool-call part → a reconstructed display card (`data-plan`,
|
|
10
15
|
* `data-presentation`) when `resolveCard` recognises
|
|
11
|
-
* the tool, else a generic tool-call part
|
|
16
|
+
* the tool, else a generic tool-call part whose detail
|
|
17
|
+
* `resolveDetail` recomputes from the persisted input
|
|
12
18
|
* - everything else → dropped (reasoning, tool-result, etc.) — the UI
|
|
13
19
|
* does not render them
|
|
14
20
|
*
|
|
15
|
-
* Display cards are emitted live over the chat SSE stream but never persisted
|
|
16
|
-
* (storage holds only the AI SDK model-message transcript). `resolveCard`
|
|
17
|
-
* rebuilds them from the persisted tool-call part so they reappear on reload,
|
|
18
|
-
* and `resolveDetail` rebuilds each call's one-line detail the same way.
|
|
19
|
-
*
|
|
20
21
|
* A `tool-result` block is not rendered, but it is not ignored either: it is the
|
|
21
22
|
* only record of how its call ended. The blocks are indexed by `toolCallId`
|
|
22
23
|
* before conversion so each tool-call part can report its own outcome — without
|
|
23
|
-
* that pass every
|
|
24
|
+
* that pass every migrated call would report success, including the failed ones.
|
|
24
25
|
*
|
|
25
26
|
* A row that yields no renderable parts (e.g. a `tool`-role message carrying
|
|
26
27
|
* only tool-result continuation parts) is omitted entirely, so the rendered
|
|
@@ -50,14 +51,16 @@ function indexOutcomes(messages) {
|
|
|
50
51
|
}
|
|
51
52
|
function genericToolCall(block, outcomes, resolveDetail) {
|
|
52
53
|
// No paired result is the shape of a transcript whose `tool` row was never
|
|
53
|
-
// appended
|
|
54
|
-
|
|
54
|
+
// appended — an aborted turn. That is not a success: the call was dispatched and
|
|
55
|
+
// never completed, which is exactly `incomplete`. Reporting `ok` would claim a
|
|
56
|
+
// result the tool never returned, and it is the one outcome the transcript
|
|
57
|
+
// genuinely cannot supply, so it is named rather than guessed at.
|
|
58
|
+
const outcome = outcomes.get(block.toolCallId) ?? "incomplete";
|
|
55
59
|
const detail = resolveDetail?.(block.toolName, block.input);
|
|
56
60
|
return {
|
|
57
61
|
type: "tool-call",
|
|
58
62
|
toolCallId: block.toolCallId,
|
|
59
63
|
toolName: block.toolName,
|
|
60
|
-
status: "finished",
|
|
61
64
|
outcome,
|
|
62
65
|
...(detail === undefined ? {} : { detail }),
|
|
63
66
|
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Pool } from "pg";
|
|
2
|
+
import type { Tool } from "../tools/define-tool.js";
|
|
3
|
+
import type { ResolveWorkspaceRoot } from "../workspace/paths.js";
|
|
4
|
+
export interface ConversationDisplayBackfillDeps {
|
|
5
|
+
readonly pool: Pool;
|
|
6
|
+
readonly resolveWorkspaceRoot: ResolveWorkspaceRoot;
|
|
7
|
+
/**
|
|
8
|
+
* The conversation agent's composed roster, used to recover each legacy call's
|
|
9
|
+
* one-line detail from its persisted input. Tools contributed through the
|
|
10
|
+
* host-tools seam are invisible to any map the harness holds on its own, so the
|
|
11
|
+
* list has to come from the assembled runtime.
|
|
12
|
+
*/
|
|
13
|
+
readonly tools: readonly Tool[];
|
|
14
|
+
readonly batchSize?: number;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Freeze the migration renderer's output into display envelopes, once per legacy
|
|
18
|
+
* turn, so the runtime read path never has to reconstruct anything.
|
|
19
|
+
*
|
|
20
|
+
* Missing mutable resources degrade to a generic, cardless display — a workspace
|
|
21
|
+
* that has moved is normal historical absence, not a fault. Malformed stored rows
|
|
22
|
+
* and database faults remain startup-fatal: a turn that cannot be migrated must
|
|
23
|
+
* not be silently skipped and then read as if it had no display.
|
|
24
|
+
*/
|
|
25
|
+
export declare function backfillConversationDisplayEnvelopes(deps: ConversationDisplayBackfillDeps): Promise<number>;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { createDetailResolver } from "../tools/detail-resolver.js";
|
|
2
|
+
import { envelopeDisplayMessages, cortexMessagesToConversationUI, parseStoredDisplayEnvelope } from "./conversation-display-storage.js";
|
|
3
|
+
import { parseStoredMessageEnvelope } from "./ai-sdk-message-storage.js";
|
|
4
|
+
import { contentToCortexMessages } from "./content-to-cortex.js";
|
|
5
|
+
import { createCardResolver } from "./reconstruct-cards.js";
|
|
6
|
+
const BATCH_SIZE = 100;
|
|
7
|
+
const GENUINE_USER_SQL = `m.message_envelope->'message'->>'role' = 'user'
|
|
8
|
+
AND m.message_envelope->'message'->'providerOptions'->'cortex'->>'synthetic' IS DISTINCT FROM 'true'`;
|
|
9
|
+
/**
|
|
10
|
+
* Freeze the migration renderer's output into display envelopes, once per legacy
|
|
11
|
+
* turn, so the runtime read path never has to reconstruct anything.
|
|
12
|
+
*
|
|
13
|
+
* Missing mutable resources degrade to a generic, cardless display — a workspace
|
|
14
|
+
* that has moved is normal historical absence, not a fault. Malformed stored rows
|
|
15
|
+
* and database faults remain startup-fatal: a turn that cannot be migrated must
|
|
16
|
+
* not be silently skipped and then read as if it had no display.
|
|
17
|
+
*/
|
|
18
|
+
export async function backfillConversationDisplayEnvelopes(deps) {
|
|
19
|
+
const batchSize = Math.max(1, deps.batchSize ?? BATCH_SIZE);
|
|
20
|
+
const resolveDetail = createDetailResolver(deps.tools);
|
|
21
|
+
let migrated = 0;
|
|
22
|
+
let validationOffset = 0;
|
|
23
|
+
for (;;) {
|
|
24
|
+
const { rows } = await deps.pool.query(`SELECT thread_id, seq::text AS seq, display_envelope
|
|
25
|
+
FROM messages
|
|
26
|
+
WHERE display_envelope IS NOT NULL
|
|
27
|
+
ORDER BY thread_id, seq
|
|
28
|
+
LIMIT $1 OFFSET $2`, [batchSize, validationOffset]);
|
|
29
|
+
await Promise.all(rows.map((row) => parseStoredDisplayEnvelope(row.display_envelope, `${row.thread_id}/${row.seq}/display`)));
|
|
30
|
+
if (rows.length < batchSize)
|
|
31
|
+
break;
|
|
32
|
+
validationOffset += rows.length;
|
|
33
|
+
}
|
|
34
|
+
for (;;) {
|
|
35
|
+
const { rows: heads } = await deps.pool.query(`SELECT m.thread_id, m.seq::text AS seq, t.analysis_id
|
|
36
|
+
FROM messages m
|
|
37
|
+
LEFT JOIN cortex_analysis_threads t ON t.thread_id = m.thread_id
|
|
38
|
+
WHERE ${GENUINE_USER_SQL}
|
|
39
|
+
AND m.display_envelope IS NULL
|
|
40
|
+
ORDER BY m.thread_id, m.seq
|
|
41
|
+
LIMIT $1`, [batchSize]);
|
|
42
|
+
if (heads.length === 0)
|
|
43
|
+
return migrated;
|
|
44
|
+
for (const head of heads) {
|
|
45
|
+
const { rows } = await deps.pool.query(`SELECT m.seq::text AS seq, m.message_envelope
|
|
46
|
+
FROM messages m
|
|
47
|
+
WHERE m.thread_id = $1
|
|
48
|
+
AND m.seq >= $2::bigint
|
|
49
|
+
AND m.seq < COALESCE(
|
|
50
|
+
(
|
|
51
|
+
SELECT MIN(next.seq)
|
|
52
|
+
FROM messages next
|
|
53
|
+
WHERE next.thread_id = m.thread_id
|
|
54
|
+
AND next.seq > $2::bigint
|
|
55
|
+
AND next.message_envelope->'message'->>'role' = 'user'
|
|
56
|
+
AND next.message_envelope->'message'->'providerOptions'->'cortex'->>'synthetic' IS DISTINCT FROM 'true'
|
|
57
|
+
),
|
|
58
|
+
9223372036854775807
|
|
59
|
+
)
|
|
60
|
+
ORDER BY m.seq`, [head.thread_id, head.seq]);
|
|
61
|
+
const stored = rows.map((row) => {
|
|
62
|
+
const envelope = parseStoredMessageEnvelope(row.message_envelope, `${head.thread_id}/${row.seq}`);
|
|
63
|
+
return { seq: Number(row.seq), envelope, message: envelope.message };
|
|
64
|
+
});
|
|
65
|
+
let resolveCard;
|
|
66
|
+
if (head.analysis_id !== null) {
|
|
67
|
+
try {
|
|
68
|
+
resolveCard = createCardResolver(deps.pool, head.analysis_id, deps.resolveWorkspaceRoot(head.analysis_id));
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
// A removed/moved workspace is normal historical absence.
|
|
72
|
+
resolveCard = undefined;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const cortex = await contentToCortexMessages(stored, { resolveCard, resolveDetail });
|
|
76
|
+
const uiMessages = cortexMessagesToConversationUI(cortex);
|
|
77
|
+
// A genuine turn always contributes at least its user message. Keep a
|
|
78
|
+
// defensive model-derived fallback so absence can never strand a NULL
|
|
79
|
+
// envelope and make startup retry forever.
|
|
80
|
+
const display = uiMessages.length > 0
|
|
81
|
+
? uiMessages
|
|
82
|
+
: [
|
|
83
|
+
{
|
|
84
|
+
id: head.seq,
|
|
85
|
+
role: "user",
|
|
86
|
+
parts: [{ type: "text", text: "", state: "done" }],
|
|
87
|
+
},
|
|
88
|
+
];
|
|
89
|
+
const envelope = envelopeDisplayMessages(display);
|
|
90
|
+
const result = await deps.pool.query(`UPDATE messages
|
|
91
|
+
SET display_envelope = $1::jsonb
|
|
92
|
+
WHERE thread_id = $2 AND seq = $3::bigint
|
|
93
|
+
AND display_envelope IS NULL`, [JSON.stringify(envelope), head.thread_id, head.seq]);
|
|
94
|
+
migrated += result.rowCount ?? 0;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { EmitFn } from "../loop/types.js";
|
|
2
|
+
import { type ConversationUIMessage } from "./conversation-display-storage.js";
|
|
3
|
+
export interface ConversationDisplayRecorder {
|
|
4
|
+
readonly emit: EmitFn;
|
|
5
|
+
finish(options?: {
|
|
6
|
+
readonly fallbackText?: string;
|
|
7
|
+
readonly interrupted?: boolean;
|
|
8
|
+
}): ConversationUIMessage[];
|
|
9
|
+
}
|
|
10
|
+
export interface ConversationDisplayRecorderOptions {
|
|
11
|
+
readonly userText: string;
|
|
12
|
+
readonly topLevelCallPath: readonly string[];
|
|
13
|
+
readonly sink: EmitFn;
|
|
14
|
+
readonly userMessageId?: string;
|
|
15
|
+
readonly assistantMessageId?: string;
|
|
16
|
+
}
|
|
17
|
+
export declare function createConversationDisplayRecorder(options: ConversationDisplayRecorderOptions): ConversationDisplayRecorder;
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { PART_REGISTRY } from "../contracts/part-registry.js";
|
|
3
|
+
import { CortexChatPartSchema } from "../contracts/schemas/chat-parts.js";
|
|
4
|
+
import { conversationDisplayPart } from "./conversation-display-storage.js";
|
|
5
|
+
function jsonCopy(value) {
|
|
6
|
+
return JSON.parse(JSON.stringify(value));
|
|
7
|
+
}
|
|
8
|
+
function isSubAgent(event, topLevelCallPath) {
|
|
9
|
+
if (!("source" in event) || event.source === undefined)
|
|
10
|
+
return false;
|
|
11
|
+
const path = event.source.callPath;
|
|
12
|
+
return path.length > topLevelCallPath.length && topLevelCallPath.every((entry, index) => path[index] === entry);
|
|
13
|
+
}
|
|
14
|
+
function durableConversationType(type) {
|
|
15
|
+
if (!Object.hasOwn(PART_REGISTRY, type))
|
|
16
|
+
return false;
|
|
17
|
+
const descriptor = PART_REGISTRY[type];
|
|
18
|
+
return descriptor.emitter === "conversation" && descriptor.consumer === "conversation" && !descriptor.transient;
|
|
19
|
+
}
|
|
20
|
+
function dataPart(event) {
|
|
21
|
+
const parsed = CortexChatPartSchema.safeParse({ type: event.type, ...event.data });
|
|
22
|
+
if (!parsed.success)
|
|
23
|
+
throw new Error(`Invalid emitted conversation part ${event.type}: ${parsed.error.message}`);
|
|
24
|
+
const { type, ...data } = parsed.data;
|
|
25
|
+
return {
|
|
26
|
+
type,
|
|
27
|
+
id: "id" in data && typeof data.id === "string" ? data.id : undefined,
|
|
28
|
+
data: jsonCopy(data),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
export function createConversationDisplayRecorder(options) {
|
|
32
|
+
const assistantParts = [];
|
|
33
|
+
// Where a reconciling part lives, keyed by `type:id`. Type-qualified because a
|
|
34
|
+
// stable id is only unique within its own family — a plan and a run card may
|
|
35
|
+
// legitimately share one.
|
|
36
|
+
const reconcileIndexes = new Map();
|
|
37
|
+
let finished = false;
|
|
38
|
+
/**
|
|
39
|
+
* Place a part, replacing the one it reconciles with when it has a stable id.
|
|
40
|
+
* A tool call reaching its `finished` state and an `ask` reaching its resolved
|
|
41
|
+
* status are the same operation: latest-wins, in the position the part first
|
|
42
|
+
* took, so a later part never jumps the ordering the user saw.
|
|
43
|
+
*/
|
|
44
|
+
function upsert(part, reconciling) {
|
|
45
|
+
if (!reconciling || part.id === undefined) {
|
|
46
|
+
assistantParts.push(part);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const key = `${part.type}:${part.id}`;
|
|
50
|
+
const index = reconcileIndexes.get(key);
|
|
51
|
+
if (index !== undefined) {
|
|
52
|
+
assistantParts[index] = part;
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
reconcileIndexes.set(key, assistantParts.length);
|
|
56
|
+
assistantParts.push(part);
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Place or update a call's recorded state.
|
|
60
|
+
*
|
|
61
|
+
* A call is recorded `incomplete` the moment it is dispatched and overwritten with
|
|
62
|
+
* its real outcome when it finishes. The record is therefore honest at every
|
|
63
|
+
* instant, and a turn that ends mid-call needs no closing pass — the calls that
|
|
64
|
+
* never finished are already saying so.
|
|
65
|
+
*/
|
|
66
|
+
function recordToolCall(toolCallId, toolName, outcome, detail) {
|
|
67
|
+
upsert(conversationDisplayPart({
|
|
68
|
+
type: "tool-call",
|
|
69
|
+
toolCallId,
|
|
70
|
+
toolName,
|
|
71
|
+
outcome,
|
|
72
|
+
...(detail === undefined ? {} : { detail }),
|
|
73
|
+
}), true);
|
|
74
|
+
}
|
|
75
|
+
function appendText(text) {
|
|
76
|
+
if (text.length === 0)
|
|
77
|
+
return;
|
|
78
|
+
const previous = assistantParts.at(-1);
|
|
79
|
+
if (previous?.type === "text") {
|
|
80
|
+
previous.text += text;
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
assistantParts.push({ type: "text", text, state: "streaming" });
|
|
84
|
+
}
|
|
85
|
+
function recordData(event) {
|
|
86
|
+
if (!durableConversationType(event.type))
|
|
87
|
+
return;
|
|
88
|
+
upsert(dataPart(event), PART_REGISTRY[event.type].reconciling);
|
|
89
|
+
}
|
|
90
|
+
const emit = (event) => {
|
|
91
|
+
if (finished)
|
|
92
|
+
return options.sink(event);
|
|
93
|
+
if (!isSubAgent(event, options.topLevelCallPath)) {
|
|
94
|
+
switch (event.type) {
|
|
95
|
+
case "text-delta":
|
|
96
|
+
appendText(event.text);
|
|
97
|
+
break;
|
|
98
|
+
// The detail the loop computed at dispatch is recorded as shown. It is
|
|
99
|
+
// NOT re-derived on reload: doing that would need the tool's schema and
|
|
100
|
+
// the raw input, which is the tool-name coupling the display projection
|
|
101
|
+
// exists to remove.
|
|
102
|
+
case "tool-started":
|
|
103
|
+
recordToolCall(event.toolUseId, event.name, "incomplete", event.detail);
|
|
104
|
+
break;
|
|
105
|
+
case "tool-finished":
|
|
106
|
+
recordToolCall(event.toolUseId, event.name, event.outcome, event.detail);
|
|
107
|
+
break;
|
|
108
|
+
case "done":
|
|
109
|
+
case "iteration":
|
|
110
|
+
break;
|
|
111
|
+
default:
|
|
112
|
+
recordData(event);
|
|
113
|
+
break;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return options.sink(event);
|
|
117
|
+
};
|
|
118
|
+
function finish(finishOptions) {
|
|
119
|
+
if (!finished) {
|
|
120
|
+
finished = true;
|
|
121
|
+
if (!assistantParts.some((part) => part.type === "text") && finishOptions?.fallbackText) {
|
|
122
|
+
appendText(finishOptions.fallbackText);
|
|
123
|
+
}
|
|
124
|
+
// Calls need no closing pass: one that never reached `tool-finished` still holds
|
|
125
|
+
// the `incomplete` it was recorded with at dispatch, which is the whole of what
|
|
126
|
+
// the harness observed. An approval does need one — `pending` is a question, and
|
|
127
|
+
// a turn that ends without answering it aborted it.
|
|
128
|
+
for (const part of assistantParts) {
|
|
129
|
+
if (part.type === "data-ask" && part.data.status === "pending") {
|
|
130
|
+
part.data.status = "aborted";
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
for (const part of assistantParts) {
|
|
134
|
+
if (part.type === "text")
|
|
135
|
+
part.state = "done";
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
const messages = [
|
|
139
|
+
{
|
|
140
|
+
id: options.userMessageId ?? randomUUID(),
|
|
141
|
+
role: "user",
|
|
142
|
+
parts: [{ type: "text", text: options.userText, state: "done" }],
|
|
143
|
+
},
|
|
144
|
+
];
|
|
145
|
+
if (assistantParts.length > 0) {
|
|
146
|
+
messages.push({
|
|
147
|
+
id: options.assistantMessageId ?? randomUUID(),
|
|
148
|
+
role: "assistant",
|
|
149
|
+
...(finishOptions?.interrupted ? { metadata: { interrupted: true } } : {}),
|
|
150
|
+
parts: jsonCopy(assistantParts),
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
return messages;
|
|
154
|
+
}
|
|
155
|
+
return { emit, finish };
|
|
156
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
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 type { CortexMessage } from "../contracts/message.js";
|
|
18
|
+
import type { StoredMessage } from "./thread-history.js";
|
|
19
|
+
export declare function storedMessagesToCortex(messages: readonly StoredMessage[]): CortexMessage[];
|