@inflexa-ai/harness 0.8.0 → 0.9.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/contracts/message.d.ts +6 -0
- package/dist/execution/report-runner.d.ts +6 -1
- package/dist/execution/report-runner.js +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/loop/run-agent.d.ts +1 -1
- package/dist/loop/run-agent.js +55 -2
- package/dist/memory/ai-sdk-message-storage.d.ts +42 -0
- package/dist/memory/ai-sdk-message-storage.js +62 -0
- package/dist/memory/content-to-cortex.d.ts +8 -1
- package/dist/memory/content-to-cortex.js +36 -4
- package/dist/memory/thread-history.d.ts +37 -0
- package/dist/memory/thread-history.js +74 -7
- package/dist/prompts/conversation.d.ts +1 -1
- package/dist/prompts/conversation.js +3 -3
- package/dist/prompts/report-builder.d.ts +1 -1
- package/dist/prompts/report-builder.js +1 -1
- package/dist/providers/streaming-chat.d.ts +11 -2
- package/dist/providers/streaming-chat.js +31 -6
- package/dist/providers/types.d.ts +10 -1
- package/dist/tools/iterate-report.d.ts +0 -1
- package/dist/tools/iterate-report.js +125 -11
- package/dist/tools/report/mint-preview-url.d.ts +1 -1
- package/dist/tools/report/mint-preview-url.js +7 -5
- package/dist/tools/report/preview-publisher.d.ts +16 -0
- package/dist/tools/report/preview-publisher.js +20 -0
- package/dist/tools/report/preview-snapshot.d.ts +9 -1
- package/dist/tools/report/preview-snapshot.js +15 -1
- package/dist/tools/report/submit-report.d.ts +1 -0
- package/dist/tools/report/submit-report.js +32 -11
- package/dist/tools/report/version-fs.d.ts +4 -4
- package/dist/tools/report/version-fs.js +24 -17
- package/package.json +1 -1
|
@@ -33,4 +33,10 @@ export interface CortexMessage {
|
|
|
33
33
|
id: string;
|
|
34
34
|
role: "user" | "assistant" | "system";
|
|
35
35
|
parts: CortexPart[];
|
|
36
|
+
/**
|
|
37
|
+
* Set when this message's production was cut off by a client abort, so the UI
|
|
38
|
+
* can badge it. Absent means not interrupted — the field is optional so every
|
|
39
|
+
* existing consumer is unaffected.
|
|
40
|
+
*/
|
|
41
|
+
interrupted?: boolean;
|
|
36
42
|
}
|
|
@@ -60,7 +60,12 @@ export interface ReportRunnerOptions {
|
|
|
60
60
|
readonly previewId: string;
|
|
61
61
|
/** Base version to iterate from. Defaults to latest. */
|
|
62
62
|
readonly baseVersion?: number;
|
|
63
|
-
/**
|
|
63
|
+
/**
|
|
64
|
+
* Format the preview was built as. The tool boundary admits only `"html"`,
|
|
65
|
+
* so no other value can reach a live iteration; the union stays wide because
|
|
66
|
+
* previews persisted with a broader vocabulary must still parse. Read this
|
|
67
|
+
* field as a record of what a preview is, not as a request for a renderer.
|
|
68
|
+
*/
|
|
64
69
|
readonly format: "html" | "pdf";
|
|
65
70
|
/** Prompt for the report-builder agent. */
|
|
66
71
|
readonly prompt: string;
|
|
@@ -128,6 +128,7 @@ export async function runReportIteration(deps, opts) {
|
|
|
128
128
|
previews: opts.previews,
|
|
129
129
|
urlCell,
|
|
130
130
|
chrome: deps.chrome,
|
|
131
|
+
logger,
|
|
131
132
|
}),
|
|
132
133
|
...createVersionFsTools({ versionDir: versionDirAbs }),
|
|
133
134
|
...Object.values(createSkillTools({ skillsDir: deps.skillsDir, skills: [...REPORT_BUILDER_SKILLS] })),
|
package/dist/index.d.ts
CHANGED
|
@@ -53,7 +53,7 @@ export type { PrepareChatTurnDeps, PrepareChatTurnParams, PrepareChatTurnResult
|
|
|
53
53
|
export { createThreadStore } from "./memory/thread-store.js";
|
|
54
54
|
export type { ThreadStore, Thread, CreateThreadInput, ListThreadsInput, ThreadPage } from "./memory/thread-store.js";
|
|
55
55
|
export { createThreadHistory } from "./memory/thread-history.js";
|
|
56
|
-
export type { ThreadHistory, StoredMessage, MessagePage } from "./memory/thread-history.js";
|
|
56
|
+
export type { ThreadHistory, StoredMessage, MessagePage, RetractOutcome } from "./memory/thread-history.js";
|
|
57
57
|
export { contentToCortexMessages } from "./memory/content-to-cortex.js";
|
|
58
58
|
export { createCardResolver } from "./memory/reconstruct-cards.js";
|
|
59
59
|
export type { ToolCardResolver, StoredToolCallForCard } from "./memory/reconstruct-cards.js";
|
package/dist/loop/run-agent.d.ts
CHANGED
|
@@ -4,7 +4,7 @@ import type { AgentChat, PromptCachePolicy } from "../providers/types.js";
|
|
|
4
4
|
import { type AskApproval, type AskRequest } from "../tools/approval/contract.js";
|
|
5
5
|
import type { AgentDefinition, EmitFn, LoopMessage, RunStep } from "./types.js";
|
|
6
6
|
export interface AgentFinish {
|
|
7
|
-
readonly reason: FinishReason | "max_iterations" | "denied";
|
|
7
|
+
readonly reason: FinishReason | "aborted" | "max_iterations" | "denied";
|
|
8
8
|
readonly cappedOut: boolean;
|
|
9
9
|
readonly truncationRecoveries: number;
|
|
10
10
|
}
|
package/dist/loop/run-agent.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { jsonSchema, tool as aiTool } from "ai";
|
|
2
2
|
import { hintForZodIssue, repairToolInput } from "../lib/zod-issues.js";
|
|
3
|
+
import { markInterruptedMessage, syntheticUserMessage } from "../memory/ai-sdk-message-storage.js";
|
|
3
4
|
import { classifyProviderError } from "../providers/errors.js";
|
|
4
5
|
import { DEFAULT_PROMPT_CACHE, promptCacheProviderOptions } from "../providers/prompt-cache.js";
|
|
5
6
|
import { resultStep } from "./run-step.js";
|
|
@@ -72,13 +73,31 @@ export async function runAgent(agent, initial, session, opts) {
|
|
|
72
73
|
};
|
|
73
74
|
const reply = await resultStep(runStep)(formatStepName.llm(i), () => provider.chat(request, session, signal));
|
|
74
75
|
addChatUsage(usage, reply.usage);
|
|
75
|
-
|
|
76
|
+
if (reply.finishReason === "aborted") {
|
|
77
|
+
// An interrupted turn keeps whatever the model produced before the cut, but
|
|
78
|
+
// never an empty shell: a partial with no content adds no message, so a
|
|
79
|
+
// no-output abort leaves the transcript at the initial prefix. The marker
|
|
80
|
+
// then rides the last assistant message this run produced — the partial when
|
|
81
|
+
// it has content, or the tool-calling step when the abort landed mid-dispatch
|
|
82
|
+
// — an assistant role no turn-boundary reader observes. "aborted" is not
|
|
83
|
+
// "tool-calls", so this falls into the terminal return below.
|
|
84
|
+
if (assistantHasContent(reply.message))
|
|
85
|
+
messages.push(reply.message);
|
|
86
|
+
markLastLoopAssistant(messages, initial.length);
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
messages.push(reply.message);
|
|
90
|
+
}
|
|
76
91
|
const toolCalls = toolCallParts(reply.message);
|
|
77
92
|
if (reply.finishReason === "length") {
|
|
78
93
|
truncationRecoveries++;
|
|
79
94
|
await emit({ type: "iteration", source, index: i, final: false });
|
|
80
95
|
if (toolCalls.length === 0) {
|
|
81
|
-
|
|
96
|
+
// Stamped synthetic, not left as a bare `user` message: the wire format needs a user turn
|
|
97
|
+
// after a truncated assistant message, but this one is the loop's own nudge, and thread
|
|
98
|
+
// storage treats a genuine `user` message as the start of a conversation turn. Unmarked, it
|
|
99
|
+
// would split this turn in two everywhere that boundary is read.
|
|
100
|
+
messages.push(syntheticUserMessage(TRUNCATED_PROSE_STEER));
|
|
82
101
|
continue;
|
|
83
102
|
}
|
|
84
103
|
const trailing = toolCalls[toolCalls.length - 1];
|
|
@@ -135,6 +154,20 @@ export async function runAgent(agent, initial, session, opts) {
|
|
|
135
154
|
// cache_write_tokens counter is what makes that waste visible.
|
|
136
155
|
const wrapUp = await resultStep(runStep)(formatStepName.llm(agent.maxIterations), () => provider.chat({ system: agent.systemPrompt, messages, tools: {}, toolChoice: "none", providerOptions }, session, signal));
|
|
137
156
|
addChatUsage(usage, wrapUp.usage);
|
|
157
|
+
if (wrapUp.finishReason === "aborted") {
|
|
158
|
+
// An abort during the tool-less wrap-up is still the user cutting the turn — the
|
|
159
|
+
// same event the in-loop path handles — so it gets the identical treatment: keep a
|
|
160
|
+
// partial only when it carries content, and stamp the marker on the last assistant
|
|
161
|
+
// this run produced. Reporting it as a plain cap-out would hide the interruption
|
|
162
|
+
// from every downstream reader; `cappedOut` stays true because the loop genuinely
|
|
163
|
+
// exhausted its iterations, while the reason carries the abort.
|
|
164
|
+
if (assistantHasContent(wrapUp.message))
|
|
165
|
+
messages.push(wrapUp.message);
|
|
166
|
+
markLastLoopAssistant(messages, initial.length);
|
|
167
|
+
await emit({ type: "iteration", source, index: agent.maxIterations, final: true });
|
|
168
|
+
recordAgentRun({ agentId: agent.id, iterations, cappedOut: true, usage });
|
|
169
|
+
return { messages, finish: { reason: "aborted", cappedOut: true, truncationRecoveries } };
|
|
170
|
+
}
|
|
138
171
|
messages.push(wrapUp.message);
|
|
139
172
|
await emit({ type: "iteration", source, index: agent.maxIterations, final: true });
|
|
140
173
|
recordAgentRun({ agentId: agent.id, iterations, cappedOut: true, usage });
|
|
@@ -145,6 +178,26 @@ function toolCallParts(message) {
|
|
|
145
178
|
return [];
|
|
146
179
|
return message.content.filter((part) => part.type === "tool-call");
|
|
147
180
|
}
|
|
181
|
+
/** Whether an aborted partial carries any content worth persisting — an empty partial contributes no message. */
|
|
182
|
+
function assistantHasContent(message) {
|
|
183
|
+
return message.content.length > 0;
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Stamp the interruption marker on the last assistant message the loop produced
|
|
187
|
+
* this run — an index at or beyond the `initial` prefix — replacing the slot with
|
|
188
|
+
* a marked copy so the mark rides into `appendTurn` and the stored row. When the
|
|
189
|
+
* turn produced no assistant message beyond `initial` (a no-output abort on a
|
|
190
|
+
* fresh turn), there is nothing to mark and the transcript is left untouched.
|
|
191
|
+
*/
|
|
192
|
+
function markLastLoopAssistant(messages, initialCount) {
|
|
193
|
+
for (let idx = messages.length - 1; idx >= initialCount; idx--) {
|
|
194
|
+
const message = messages[idx];
|
|
195
|
+
if (message.role === "assistant") {
|
|
196
|
+
messages[idx] = markInterruptedMessage(message);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
148
201
|
async function dispatchTools(toolUses, toolsById, toolCtx, isFatalLoopError, runStep, toolStepName) {
|
|
149
202
|
const results = new Array(toolUses.length);
|
|
150
203
|
const stepTools = [];
|
|
@@ -9,6 +9,48 @@ export declare const StoredMessageEnvelopeSchema: z.ZodObject<{
|
|
|
9
9
|
export type StoredMessageEnvelope = z.infer<typeof StoredMessageEnvelopeSchema>;
|
|
10
10
|
export declare function envelopeMessage(message: ModelMessage): StoredMessageEnvelope;
|
|
11
11
|
export declare function parseStoredMessageEnvelope(value: unknown, identity: string): StoredMessageEnvelope;
|
|
12
|
+
/**
|
|
13
|
+
* The `providerOptions` namespace the harness stamps its OWN message metadata into.
|
|
14
|
+
*
|
|
15
|
+
* A provider SDK reads only its own namespace (`anthropic`, `openai`, …) and ignores every other key, so
|
|
16
|
+
* a harness-owned namespace rides to the wire and back untouched; `modelMessageSchema` validates
|
|
17
|
+
* `providerOptions` structurally rather than against a list of known namespaces, so it also survives the
|
|
18
|
+
* storage envelope round-trip. That round-trip is the whole reason the marker lives here rather than on
|
|
19
|
+
* the envelope: readers of STORED rows need it, and `ModelMessage` offers no other field able to carry a
|
|
20
|
+
* harness fact from the loop, through `appendTurn`, into a row.
|
|
21
|
+
*/
|
|
22
|
+
export declare const HARNESS_PROVIDER_NAMESPACE = "cortex";
|
|
23
|
+
/** The {@link HARNESS_PROVIDER_NAMESPACE} key marking a message the loop synthesized rather than one a human sent. */
|
|
24
|
+
export declare const SYNTHETIC_MESSAGE_KEY = "synthetic";
|
|
25
|
+
/** The {@link HARNESS_PROVIDER_NAMESPACE} key marking an assistant message whose production a client abort cut off. */
|
|
26
|
+
export declare const INTERRUPTED_MESSAGE_KEY = "interrupted";
|
|
27
|
+
/**
|
|
28
|
+
* Return a copy of `message` carrying the interruption marker in the harness namespace.
|
|
29
|
+
*
|
|
30
|
+
* The marker lives in {@link HARNESS_PROVIDER_NAMESPACE} for the same reason the synthetic marker does:
|
|
31
|
+
* it is the only field of a `ModelMessage` that survives the loop → {@link envelopeMessage} → stored-row
|
|
32
|
+
* round trip, so a reader of stored rows can still tell an interrupted reply apart. It rides the
|
|
33
|
+
* **assistant** role, which is not a turn boundary, so no boundary reader (`isGenuineUserStart`, the
|
|
34
|
+
* tail-retract predicate, window snapping) can observe it. Any existing `providerOptions` namespace is
|
|
35
|
+
* preserved — a signed thinking signature or cache directive on the same message must not be dropped — so
|
|
36
|
+
* this merges into the harness namespace rather than replacing the whole `providerOptions`.
|
|
37
|
+
*/
|
|
38
|
+
export declare function markInterruptedMessage(message: ModelMessage): ModelMessage;
|
|
39
|
+
/** Whether a client abort cut off this message's production (see {@link markInterruptedMessage}); an absent key reads as not interrupted. */
|
|
40
|
+
export declare function isInterruptedMessage(message: ModelMessage): boolean;
|
|
41
|
+
/**
|
|
42
|
+
* Build a `user` message the agent loop synthesized — today, the nudge that continues a reply the model
|
|
43
|
+
* truncated at its output-token limit.
|
|
44
|
+
*
|
|
45
|
+
* It has to carry the `user` role because the wire format requires a user turn after a truncated
|
|
46
|
+
* assistant message, but it is not user input, and the difference is load-bearing: a `user` message is
|
|
47
|
+
* what OPENS a conversation turn, so an unmarked synthetic one reads as a spurious turn boundary —
|
|
48
|
+
* splitting one turn into two for the token window and, worse, giving a tail-turn removal a cut point in
|
|
49
|
+
* the middle of a turn. Marking it is what lets {@link isSyntheticUserMessage} keep those readers honest.
|
|
50
|
+
*/
|
|
51
|
+
export declare function syntheticUserMessage(text: string): ModelMessage;
|
|
52
|
+
/** Whether the loop synthesized this message (see {@link syntheticUserMessage}) rather than a human sending it. */
|
|
53
|
+
export declare function isSyntheticUserMessage(message: ModelMessage): boolean;
|
|
12
54
|
type LegacyContent = string | Array<Record<string, unknown>>;
|
|
13
55
|
export interface LegacyMessageRow {
|
|
14
56
|
readonly threadId: string;
|
|
@@ -20,6 +20,68 @@ export function parseStoredMessageEnvelope(value, identity) {
|
|
|
20
20
|
}
|
|
21
21
|
return parsed.data;
|
|
22
22
|
}
|
|
23
|
+
/**
|
|
24
|
+
* The `providerOptions` namespace the harness stamps its OWN message metadata into.
|
|
25
|
+
*
|
|
26
|
+
* A provider SDK reads only its own namespace (`anthropic`, `openai`, …) and ignores every other key, so
|
|
27
|
+
* a harness-owned namespace rides to the wire and back untouched; `modelMessageSchema` validates
|
|
28
|
+
* `providerOptions` structurally rather than against a list of known namespaces, so it also survives the
|
|
29
|
+
* storage envelope round-trip. That round-trip is the whole reason the marker lives here rather than on
|
|
30
|
+
* the envelope: readers of STORED rows need it, and `ModelMessage` offers no other field able to carry a
|
|
31
|
+
* harness fact from the loop, through `appendTurn`, into a row.
|
|
32
|
+
*/
|
|
33
|
+
export const HARNESS_PROVIDER_NAMESPACE = "cortex";
|
|
34
|
+
/** The {@link HARNESS_PROVIDER_NAMESPACE} key marking a message the loop synthesized rather than one a human sent. */
|
|
35
|
+
export const SYNTHETIC_MESSAGE_KEY = "synthetic";
|
|
36
|
+
/** The {@link HARNESS_PROVIDER_NAMESPACE} key marking an assistant message whose production a client abort cut off. */
|
|
37
|
+
export const INTERRUPTED_MESSAGE_KEY = "interrupted";
|
|
38
|
+
/**
|
|
39
|
+
* Return a copy of `message` carrying the interruption marker in the harness namespace.
|
|
40
|
+
*
|
|
41
|
+
* The marker lives in {@link HARNESS_PROVIDER_NAMESPACE} for the same reason the synthetic marker does:
|
|
42
|
+
* it is the only field of a `ModelMessage` that survives the loop → {@link envelopeMessage} → stored-row
|
|
43
|
+
* round trip, so a reader of stored rows can still tell an interrupted reply apart. It rides the
|
|
44
|
+
* **assistant** role, which is not a turn boundary, so no boundary reader (`isGenuineUserStart`, the
|
|
45
|
+
* tail-retract predicate, window snapping) can observe it. Any existing `providerOptions` namespace is
|
|
46
|
+
* preserved — a signed thinking signature or cache directive on the same message must not be dropped — so
|
|
47
|
+
* this merges into the harness namespace rather than replacing the whole `providerOptions`.
|
|
48
|
+
*/
|
|
49
|
+
export function markInterruptedMessage(message) {
|
|
50
|
+
const existingOptions = message.providerOptions ?? {};
|
|
51
|
+
const existingHarness = existingOptions[HARNESS_PROVIDER_NAMESPACE] ?? {};
|
|
52
|
+
return {
|
|
53
|
+
...message,
|
|
54
|
+
providerOptions: {
|
|
55
|
+
...existingOptions,
|
|
56
|
+
[HARNESS_PROVIDER_NAMESPACE]: { ...existingHarness, [INTERRUPTED_MESSAGE_KEY]: true },
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
/** Whether a client abort cut off this message's production (see {@link markInterruptedMessage}); an absent key reads as not interrupted. */
|
|
61
|
+
export function isInterruptedMessage(message) {
|
|
62
|
+
return message.providerOptions?.[HARNESS_PROVIDER_NAMESPACE]?.[INTERRUPTED_MESSAGE_KEY] === true;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Build a `user` message the agent loop synthesized — today, the nudge that continues a reply the model
|
|
66
|
+
* truncated at its output-token limit.
|
|
67
|
+
*
|
|
68
|
+
* It has to carry the `user` role because the wire format requires a user turn after a truncated
|
|
69
|
+
* assistant message, but it is not user input, and the difference is load-bearing: a `user` message is
|
|
70
|
+
* what OPENS a conversation turn, so an unmarked synthetic one reads as a spurious turn boundary —
|
|
71
|
+
* splitting one turn into two for the token window and, worse, giving a tail-turn removal a cut point in
|
|
72
|
+
* the middle of a turn. Marking it is what lets {@link isSyntheticUserMessage} keep those readers honest.
|
|
73
|
+
*/
|
|
74
|
+
export function syntheticUserMessage(text) {
|
|
75
|
+
return {
|
|
76
|
+
role: "user",
|
|
77
|
+
content: text,
|
|
78
|
+
providerOptions: { [HARNESS_PROVIDER_NAMESPACE]: { [SYNTHETIC_MESSAGE_KEY]: true } },
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
/** Whether the loop synthesized this message (see {@link syntheticUserMessage}) rather than a human sending it. */
|
|
82
|
+
export function isSyntheticUserMessage(message) {
|
|
83
|
+
return message.providerOptions?.[HARNESS_PROVIDER_NAMESPACE]?.[SYNTHETIC_MESSAGE_KEY] === true;
|
|
84
|
+
}
|
|
23
85
|
function providerOptionsFromLegacy(block) {
|
|
24
86
|
// A block may carry both a signed thinking signature and cache_control;
|
|
25
87
|
// both are required for valid continuation, so both must survive.
|
|
@@ -25,9 +25,11 @@ import type { ToolCardResolver } from "./reconstruct-cards.js";
|
|
|
25
25
|
import type { StoredMessage } from "./thread-history.js";
|
|
26
26
|
/**
|
|
27
27
|
* Map stored messages (oldest-first) to `CortexMessage[]`. Rows that produce
|
|
28
|
-
* no renderable parts are dropped, and consecutive
|
|
28
|
+
* no renderable parts are dropped, and consecutive assistant rows are coalesced
|
|
29
29
|
* into one message. The first row's `seq` becomes the stable message id.
|
|
30
30
|
* `resolveCard` (optional) reconstructs display cards from `tool_use` blocks.
|
|
31
|
+
* A row carrying the interruption marker sets `interrupted: true` on the message
|
|
32
|
+
* it lands in, including when coalesced into an assistant run.
|
|
31
33
|
*
|
|
32
34
|
* Coalescing mirrors the live SSE shape. The agent loop persists each assistant
|
|
33
35
|
* step (typically a single `tool_use`) as its own row, and the `tool_result`
|
|
@@ -38,5 +40,10 @@ import type { StoredMessage } from "./thread-history.js";
|
|
|
38
40
|
* tool call, which the UI renders as a stack of single-tool boxes. Merging the
|
|
39
41
|
* run restores the one-bubble-per-turn shape (and lets the UI collapse the tool
|
|
40
42
|
* calls into one group) regardless of how the loop split the turn into rows.
|
|
43
|
+
*
|
|
44
|
+
* Coalescing is assistant-only. User rows never legitimately split into a run —
|
|
45
|
+
* adjacent `user` rows arise only from turns that persisted no reply (an aborted
|
|
46
|
+
* turn's lone user message followed by the next turn's), and merging them would
|
|
47
|
+
* fabricate one bubble from two messages the user sent separately.
|
|
41
48
|
*/
|
|
42
49
|
export declare function contentToCortexMessages(messages: readonly StoredMessage[], resolveCard?: ToolCardResolver): Promise<CortexMessage[]>;
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
* only tool-result continuation parts) is omitted entirely, so the rendered
|
|
21
21
|
* conversation has no empty bubbles. Storage is never mutated.
|
|
22
22
|
*/
|
|
23
|
+
import { isInterruptedMessage, isSyntheticUserMessage } from "./ai-sdk-message-storage.js";
|
|
23
24
|
function genericToolCall(block) {
|
|
24
25
|
return {
|
|
25
26
|
type: "tool-call",
|
|
@@ -53,9 +54,11 @@ async function rowToParts(message, resolveCard) {
|
|
|
53
54
|
}
|
|
54
55
|
/**
|
|
55
56
|
* Map stored messages (oldest-first) to `CortexMessage[]`. Rows that produce
|
|
56
|
-
* no renderable parts are dropped, and consecutive
|
|
57
|
+
* no renderable parts are dropped, and consecutive assistant rows are coalesced
|
|
57
58
|
* into one message. The first row's `seq` becomes the stable message id.
|
|
58
59
|
* `resolveCard` (optional) reconstructs display cards from `tool_use` blocks.
|
|
60
|
+
* A row carrying the interruption marker sets `interrupted: true` on the message
|
|
61
|
+
* it lands in, including when coalesced into an assistant run.
|
|
59
62
|
*
|
|
60
63
|
* Coalescing mirrors the live SSE shape. The agent loop persists each assistant
|
|
61
64
|
* step (typically a single `tool_use`) as its own row, and the `tool_result`
|
|
@@ -66,20 +69,49 @@ async function rowToParts(message, resolveCard) {
|
|
|
66
69
|
* tool call, which the UI renders as a stack of single-tool boxes. Merging the
|
|
67
70
|
* run restores the one-bubble-per-turn shape (and lets the UI collapse the tool
|
|
68
71
|
* calls into one group) regardless of how the loop split the turn into rows.
|
|
72
|
+
*
|
|
73
|
+
* Coalescing is assistant-only. User rows never legitimately split into a run —
|
|
74
|
+
* adjacent `user` rows arise only from turns that persisted no reply (an aborted
|
|
75
|
+
* turn's lone user message followed by the next turn's), and merging them would
|
|
76
|
+
* fabricate one bubble from two messages the user sent separately.
|
|
69
77
|
*/
|
|
70
78
|
export async function contentToCortexMessages(messages, resolveCard) {
|
|
71
79
|
const out = [];
|
|
72
80
|
for (const message of messages) {
|
|
81
|
+
// The loop's truncation nudge carries the `user` role only for the wire
|
|
82
|
+
// format; rendering it would show words the user never typed, so drop it.
|
|
83
|
+
// Skipping first is safe: a synthetic user message can never carry the
|
|
84
|
+
// interruption marker — that rides assistant rows — so this cannot interact
|
|
85
|
+
// with the marker-fold logic below. The assistant rows it separated then
|
|
86
|
+
// become adjacent and coalesce into one bubble, which is the shape we want.
|
|
87
|
+
if (isSyntheticUserMessage(message.message))
|
|
88
|
+
continue;
|
|
73
89
|
const parts = await rowToParts(message.message, resolveCard);
|
|
74
|
-
|
|
90
|
+
// The marker is read before the zero-parts drop so the flag can never be lost to it:
|
|
91
|
+
// a marked row carries an interruption fact regardless of whether it renders any parts.
|
|
92
|
+
// When such a row contributes no bubble of its own, the interruption belongs to the
|
|
93
|
+
// assistant run it trailed, so fold the flag onto the previous emitted assistant.
|
|
94
|
+
const interrupted = isInterruptedMessage(message.message);
|
|
95
|
+
if (parts.length === 0) {
|
|
96
|
+
if (interrupted) {
|
|
97
|
+
const prev = out[out.length - 1];
|
|
98
|
+
if (prev && prev.role === "assistant")
|
|
99
|
+
prev.interrupted = true;
|
|
100
|
+
}
|
|
75
101
|
continue;
|
|
102
|
+
}
|
|
76
103
|
const role = message.message.role === "tool" ? "assistant" : message.message.role;
|
|
77
104
|
const prev = out[out.length - 1];
|
|
78
|
-
if (prev && prev.role === role) {
|
|
105
|
+
if (prev && prev.role === role && role === "assistant") {
|
|
79
106
|
prev.parts.push(...parts);
|
|
107
|
+
if (interrupted)
|
|
108
|
+
prev.interrupted = true;
|
|
80
109
|
continue;
|
|
81
110
|
}
|
|
82
|
-
|
|
111
|
+
const cortex = { id: String(message.seq), role, parts };
|
|
112
|
+
if (interrupted)
|
|
113
|
+
cortex.interrupted = true;
|
|
114
|
+
out.push(cortex);
|
|
83
115
|
}
|
|
84
116
|
return out;
|
|
85
117
|
}
|
|
@@ -43,6 +43,21 @@ export interface MessagePage {
|
|
|
43
43
|
readonly perPage: number;
|
|
44
44
|
readonly hasMore: boolean;
|
|
45
45
|
}
|
|
46
|
+
/**
|
|
47
|
+
* The result of `retractLastTurn`. `retracted` carries `messages` — the number
|
|
48
|
+
* of rows removed — so a caller can assert exactly what came off the tail. The
|
|
49
|
+
* other two variants delete nothing and are distinct on purpose: `empty-thread`
|
|
50
|
+
* had no rows at all, while `no-user-turn` had rows but none opening a turn —
|
|
51
|
+
* anomalous data refused rather than silently emptied.
|
|
52
|
+
*/
|
|
53
|
+
export type RetractOutcome = {
|
|
54
|
+
kind: "retracted";
|
|
55
|
+
messages: number;
|
|
56
|
+
} | {
|
|
57
|
+
kind: "empty-thread";
|
|
58
|
+
} | {
|
|
59
|
+
kind: "no-user-turn";
|
|
60
|
+
};
|
|
46
61
|
/**
|
|
47
62
|
* The conversation message store. Two methods, by design — no generic row
|
|
48
63
|
* insert (see the harness-thread-store spec). `threadId` is the conversation scope — one UI thread.
|
|
@@ -69,6 +84,28 @@ export interface ThreadHistory {
|
|
|
69
84
|
* holds the flattened rows of the selected turns.
|
|
70
85
|
*/
|
|
71
86
|
loadPage(threadId: string, page: number, perPage: number): ResultAsync<MessagePage, DbError>;
|
|
87
|
+
/**
|
|
88
|
+
* Remove the thread's most recent turn — every row from the last
|
|
89
|
+
* genuine-user-start `seq` onward — in a single transaction.
|
|
90
|
+
*
|
|
91
|
+
* Tail-only by design: removal cuts at the last genuine-user-start `seq` —
|
|
92
|
+
* a turn boundary, not the last append. Because every conversation turn
|
|
93
|
+
* opens with the user's message, that boundary starts the most recent turn,
|
|
94
|
+
* so removing it restores the row set the thread held before that turn and
|
|
95
|
+
* leaves `loadRecent`'s byte-stable prompt-cache prefix untouched; an
|
|
96
|
+
* out-of-contract assistant-only follow-up append carries no user-start row
|
|
97
|
+
* and so folds into the removed tail, exactly as the read side's turn
|
|
98
|
+
* grouping folds it. Deleting mid-history would shift the window head and
|
|
99
|
+
* rewrite that prefix, so it is deliberately not offered — only the tail
|
|
100
|
+
* can come off.
|
|
101
|
+
*
|
|
102
|
+
* Callers are assumed single-writer per thread (the host serializes turns);
|
|
103
|
+
* the outcome's `messages` count lets a caller assert exactly what was
|
|
104
|
+
* removed. A thread that has rows but no genuine-user-start row is anomalous
|
|
105
|
+
* data — refused as `no-user-turn` with nothing deleted, never emptied. An
|
|
106
|
+
* empty thread reports `empty-thread`.
|
|
107
|
+
*/
|
|
108
|
+
retractLastTurn(threadId: string): ResultAsync<RetractOutcome, DbError>;
|
|
72
109
|
}
|
|
73
110
|
/**
|
|
74
111
|
* Drop the memoized instruments so the next `loadRecent` rebinds to a
|
|
@@ -21,22 +21,46 @@
|
|
|
21
21
|
* rounds the budget walk to turn boundaries.
|
|
22
22
|
*/
|
|
23
23
|
import { metrics } from "@opentelemetry/api";
|
|
24
|
-
import { ResultAsync, ok } from "neverthrow";
|
|
24
|
+
import { ResultAsync, ok, okAsync } from "neverthrow";
|
|
25
25
|
import { tryMutation, tryQuery, withTransaction } from "../lib/db-result.js";
|
|
26
26
|
import { countTokens } from "./count-tokens.js";
|
|
27
|
-
import { envelopeMessage, parseStoredMessageEnvelope } from "./ai-sdk-message-storage.js";
|
|
27
|
+
import { HARNESS_PROVIDER_NAMESPACE, SYNTHETIC_MESSAGE_KEY, envelopeMessage, isSyntheticUserMessage, parseStoredMessageEnvelope, } from "./ai-sdk-message-storage.js";
|
|
28
28
|
/** A resolved `ok(undefined)` ResultAsync — the empty/seed transaction step. */
|
|
29
29
|
function okVoid() {
|
|
30
30
|
return new ResultAsync(Promise.resolve(ok(undefined)));
|
|
31
31
|
}
|
|
32
32
|
/**
|
|
33
|
-
* A turn starts on a `user` message
|
|
34
|
-
*
|
|
35
|
-
*
|
|
33
|
+
* A turn starts on a `user` message that a human actually sent. Two kinds of
|
|
34
|
+
* message carry the `user` role without opening a turn, and both are excluded
|
|
35
|
+
* here:
|
|
36
|
+
*
|
|
37
|
+
* - a tool result, which in AI SDK terms is a `tool`-role message, so a mid-turn
|
|
38
|
+
* tool continuation never matches in the first place;
|
|
39
|
+
* - a message the LOOP synthesized mid-turn — the truncated-reply nudge
|
|
40
|
+
* (`syntheticUserMessage`) — which must carry the `user` role for the wire
|
|
41
|
+
* format but is not user input. Reading it as a boundary would split one turn
|
|
42
|
+
* into two: the token window would evict half a turn, and `retractLastTurn`
|
|
43
|
+
* would cut its tail in the middle of a turn rather than at its head.
|
|
44
|
+
*
|
|
45
|
+
* {@link GENUINE_USER_START_SQL} is the twin of this predicate over stored
|
|
46
|
+
* envelopes; the two are built from the same constants so they cannot drift.
|
|
36
47
|
*/
|
|
37
48
|
function isGenuineUserStart(message) {
|
|
38
|
-
return message.role === "user";
|
|
49
|
+
return message.role === "user" && !isSyntheticUserMessage(message);
|
|
39
50
|
}
|
|
51
|
+
/**
|
|
52
|
+
* {@link isGenuineUserStart} expressed over a stored `message_envelope` — the
|
|
53
|
+
* boundary predicate `retractLastTurn` cuts on.
|
|
54
|
+
*
|
|
55
|
+
* Interpolating here, and only here, is safe: both interpolated values are
|
|
56
|
+
* module constants shared with the TypeScript predicate, never caller input, so
|
|
57
|
+
* there is no injection surface — and a bound parameter cannot express a JSON
|
|
58
|
+
* path anyway. `IS DISTINCT FROM` (not `<> 'true'`) because `->>` yields NULL on
|
|
59
|
+
* every message that carries no `providerOptions` at all, which is nearly all of
|
|
60
|
+
* them; a plain inequality would discard them.
|
|
61
|
+
*/
|
|
62
|
+
const GENUINE_USER_START_SQL = `message_envelope->'message'->>'role' = 'user'
|
|
63
|
+
AND message_envelope->'message'->'providerOptions'->'${HARNESS_PROVIDER_NAMESPACE}'->>'${SYNTHETIC_MESSAGE_KEY}' IS DISTINCT FROM 'true'`;
|
|
40
64
|
/**
|
|
41
65
|
* Group rows (oldest-first) into turns at genuine-user-start boundaries.
|
|
42
66
|
* Generic over the row shape so the token-windowed read (`loadRecent`) and
|
|
@@ -223,5 +247,48 @@ export function createThreadHistory(pool) {
|
|
|
223
247
|
};
|
|
224
248
|
});
|
|
225
249
|
}
|
|
226
|
-
|
|
250
|
+
function retractLastTurn(threadId) {
|
|
251
|
+
return withTransaction(pool, "thread-history.retractLastTurn", (client) =>
|
|
252
|
+
// Take the SAME per-thread advisory lock `appendTurn` takes, and take it
|
|
253
|
+
// FIRST — a retract and an append on one thread must never interleave, or
|
|
254
|
+
// the retract could read a boundary mid-append and delete only part of a
|
|
255
|
+
// turn still being written. Serialized on this lock it always sees a whole
|
|
256
|
+
// turn or none of one. Released automatically at COMMIT/ROLLBACK.
|
|
257
|
+
tryQuery("thread-history.retractLastTurn.lock", () => client.query("SELECT pg_advisory_xact_lock(hashtext($1))", [threadId]))
|
|
258
|
+
.andThen(() =>
|
|
259
|
+
// Locate the tail turn's opening row and, in the same read, learn
|
|
260
|
+
// whether the thread has any rows at all. The boundary is the greatest
|
|
261
|
+
// `seq` matching `GENUINE_USER_START_SQL` — the stored-envelope twin of
|
|
262
|
+
// `isGenuineUserStart`, so a `tool`-role continuation and a
|
|
263
|
+
// loop-synthesized nudge are both excluded and the cut lands on a real
|
|
264
|
+
// turn head. The coupling is test-guarded: an append-then-retract
|
|
265
|
+
// round-trip fails loudly (the just-appended turn reads back as
|
|
266
|
+
// `no-user-turn` instead of `retracted`) the moment this envelope path
|
|
267
|
+
// drifts from what `isGenuineUserStart` reads on the live message.
|
|
268
|
+
// `has_rows` separates the two nothing-to-delete cases: an empty thread
|
|
269
|
+
// (`empty-thread`) from one holding no turn-opening row at all
|
|
270
|
+
// (`no-user-turn`, anomalous data we refuse rather than empty).
|
|
271
|
+
// `MAX(seq)` aggregates the bigint column; the `::text` cast is
|
|
272
|
+
// transport only, so no comparison ever runs against a text projection —
|
|
273
|
+
// and the boundary rides back as text so a seq beyond 2^53 survives
|
|
274
|
+
// without float rounding.
|
|
275
|
+
tryQuery("thread-history.retractLastTurn.boundary", async () => {
|
|
276
|
+
const { rows } = await client.query(`SELECT EXISTS(SELECT 1 FROM messages WHERE thread_id = $1) AS has_rows,
|
|
277
|
+
(SELECT MAX(seq)::text FROM messages
|
|
278
|
+
WHERE thread_id = $1
|
|
279
|
+
AND ${GENUINE_USER_START_SQL}) AS boundary`, [threadId]);
|
|
280
|
+
return rows[0];
|
|
281
|
+
}))
|
|
282
|
+
.andThen(({ has_rows, boundary }) => {
|
|
283
|
+
if (!has_rows)
|
|
284
|
+
return okAsync({ kind: "empty-thread" });
|
|
285
|
+
if (boundary === null)
|
|
286
|
+
return okAsync({ kind: "no-user-turn" });
|
|
287
|
+
// Delete the whole tail turn: every row at or past the boundary seq.
|
|
288
|
+
// `$2::bigint` compares the bigint column against a bigint, never a
|
|
289
|
+
// text projection, keeping the comparison exact for large seqs.
|
|
290
|
+
return tryMutation("thread-history.retractLastTurn.delete", () => client.query("DELETE FROM messages WHERE thread_id = $1 AND seq >= $2::bigint", [threadId, boundary])).map((res) => ({ kind: "retracted", messages: res.rowCount ?? 0 }));
|
|
291
|
+
}));
|
|
292
|
+
}
|
|
293
|
+
return { appendTurn, loadRecent, loadPage, retractLastTurn };
|
|
227
294
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const conversationPrompt = "# Conversation Agent\n\nYou are the user-facing companion for a bioinformatics analysis platform.\nYou help researchers explore their data, plan and execute analyses, interpret\nresults, and generate testable hypotheses. You handle everything\nconversational \u2014 from simple lookups to multi-step scientific exploration.\n\n## Your Role\n\n1. **Answer directly** \u2014 data questions, bio-lookups, file exploration,\n general bioinformatics questions. Use your tools.\n2. **Plan and execute analyses** \u2014 orient in the workspace, generate plans\n via `generate_plan`, present plans via `show_plan`, execute via `execute_plan`.\n3. **Interpret results** \u2014 after runs complete, read summaries and artifacts,\n synthesize findings, grade evidence, flag novelty.\n4. **Explore hypotheses** \u2014 investigate data, cross-reference with biology,\n generate testable hypotheses, refine through discussion.\n\n## Context Continuity\n\nYou are one agent handling the full conversation. Your tool call results\npersist across turns. If you searched the workspace, read a file, or\ngenerated a plan \u2014 those results are still in your context. Do NOT\nrepeat searches or re-orient when the information is already available\nfrom a prior turn.\n\nRe-orient only when:\n- Starting a conversation with no prior workspace exploration\n- The user asks about something not covered by prior searches\n- A new workflow run has completed since your last search\n\n## Data Discovery \u2014 Search First\n\nRoute by what you are asking for:\n\n1. **What the dataset IS** \u2192 `inspect_data_profile`. The authoritative record:\n domain, organism, tissue, condition, experimental design, quality concerns,\n and the per-file data types and dimensions. There is no data-profile file \u2014\n this tool is the only way to read it. Do not go looking for one, and do not\n re-derive these facts by reading the raw inputs.\n2. **Which files exist** \u2192 `workspace_search`. Semantic search over the indexed\n workspace; it returns ranked paths with descriptions and metadata, not\n contents.\n3. **What is inside a file** \u2192 `read_file` (or `list_files`) on a path a search\n returned.\n\nDo not start with filesystem exploration for general data questions.\nDo not tell the user you cannot access their files \u2014 you can.\n\n## Analysis Planning\n\nWhen the user wants to run an analysis:\n\n1. **Orient** \u2014 `inspect_data_profile` for the data context (organism, design,\n per-file types), `workspace_search` for prior run outputs, `inspect_run` for\n run history.\n2. **Generate the plan** \u2014 call `generate_plan` with the research question,\n any prior run results, and the user's constraints. The dataset's own facts\n (organism, domain, experimental design, condition names, quality concerns,\n per-file types and dimensions) are read server-side from the persisted data\n profile and handed to the planner directly \u2014 do NOT summarize or re-type\n them into the call. Use `analystNotes` only for facts the user told you\n that the profile cannot know (e.g. \"samples 3 and 7 were re-sequenced\").\n The tool returns a `planId` along with the plan on success.\n3. **Handle the response**:\n - **plan_complete** \u2192 present the plan via `show_plan({ planId })`\n using the `planId` from the tool return, and explain the analytical\n narrative in text. Ask for approval.\n - **clarification_needed** \u2192 relay the question to the user.\n - **error** \u2192 explain the issue and suggest alternatives.\n4. **Iterate** \u2014 if the user wants changes, call `generate_plan` again,\n passing the prior `planId` as `parentPlanId` and the user's feedback\n as `userConstraints`. This produces a new `planId` linked to the\n previous one.\n5. **Execute** \u2014 on approval, call `execute_plan({ planId })` with the\n approved `planId`. The tool resolves the plan server-side and starts\n the workflow. On validation failure, regenerate via `generate_plan`.\n6. **Plan stale / invalid?** If `execute_plan` returns `status: not_found`\n (or `invalid_plan`), or `show_plan` returns `error: plan_not_found`,\n the `planId` is no longer valid \u2014 regenerate via `generate_plan` and\n present the new plan for approval. Do NOT retry the same `planId`.\n\nThe workflow runs autonomously \u2014 you do not monitor or evaluate it.\n\n## Interpreting Results\n\nAfter a run completes, the workflow automatically produces two types of output:\n- **Step summaries** (`summary.md` per step) \u2014 computational findings,\n method choices, quality assessment. Pure facts, no literature.\n- **Run synthesis** (`synthesis.json` at run level) \u2014 integrated\n interpretation of the run: conclusions, selective key findings\n (novel/contradicted/high-impact only), biological themes, limitations,\n and literature references with PMIDs. Produced only when the run had\n summaries to integrate and hit no blocker \u2014 `synthesis.json` exists\n for that run alone; a skipped or failed synthesis writes no file.\n\nTo interpret results:\n\n1. **Check whether the run produced a synthesis** \u2014 `inspect_run({ runId })`\n reports the run's `synthesisStatus` and gives a `synthesisPath` only when it\n is `produced`. When the run produced a synthesis,\n `workspace_search(\"synthesis conclusions interpretation\")` also finds\n `synthesis.json` \u2014 that integrated interpretation, with conclusions and\n literature grounding, is your primary source. When synthesis was skipped or\n failed, no synthesis file exists \u2014 fall back to the per-step `summary.md`\n files as your source and integrate the findings yourself.\n2. **Read the synthesis** \u2014 when present, it has conclusions (the \"so what\"),\n selective key findings graded by novelty, biological themes, and\n explicit limitations with key references.\n3. **Read step summaries for detail** \u2014 when the synthesis references a finding,\n read the corresponding step's `summary.md` for the full evidence basis\n (metrics, methods, quality assessment).\n4. **Dig deeper on specific findings** \u2014 for follow-up questions, use bio-lookup\n tools directly or delegate to `literature_reviewer` for additional investigation\n beyond what the synthesis already covers.\n\nGroup findings by biological theme (e.g., immune response, metabolic\nchanges) rather than by analysis step. Clearly separate well-supported\nfindings from speculative interpretations. Every conclusion must reference\nspecific quantitative results from the step summaries.\n\n## Translational Assessment\n\nWhen interpreting results that have translational implications (therapeutic\ntargets, biomarkers, patient stratification, safety signals):\n\n1. **Check translationalRelevance** \u2014 if the synthesis includes\n translationalRelevance on findings, present the stage and actionType\n to the user. Group findings by translational category (targets,\n biomarkers, safety) rather than by analysis step.\n\n2. **Ground targets in existing therapeutics** \u2014 for findings flagged as\n therapeutic-target or mechanism-of-action, check whether approved drugs\n or clinical-stage compounds already exist for the target, and present\n the competitive landscape.\n\n3. **Distinguish predictive from prognostic** \u2014 when presenting biomarker\n findings, always clarify: a predictive biomarker says \"this patient\n will respond to treatment X\" (requires treatment x marker interaction);\n a prognostic biomarker says \"this patient has better/worse outcome\n regardless of treatment.\" Conflating these is a common error.\n\n4. **Flag translational gaps** \u2014 explicitly state what would be needed\n to advance a finding: analytical validation for biomarkers,\n selectivity profiling for targets, independent cohort validation for\n signatures, safety pharmacology for drug candidates.\n\n5. **Triangulate the evidence** \u2014 never rest a translational claim on one\n source. Cross the genetic association against the safety liabilities,\n the in-vivo loss-of-function phenotype, and the clinical landscape\n before you call a target promising.\n\n## Literature & Biology Investigation\n\nYou have bio-lookup tools for quick lookups AND a `literature_reviewer`\nagent for deep batch investigation. Route appropriately:\n\n**Handle directly** (1-2 targets, quick answer):\n- \"What does BRCA1 do?\" \u2192 `search_gene`\n- \"What pathways involve TP53?\" \u2192 `search_pathway`\n- \"Any papers on EGFR in AD?\" \u2192 `pubmed`\n\n**Delegate to `literature_reviewer`** (3+ targets, systematic investigation):\n- \"Investigate these top DE genes against literature\"\n- \"Build evidence profiles for these enriched pathways\"\n- \"Validate these findings \u2014 are they novel or established?\"\n\nIt returns a structured evidence report. Use it to ground your hypotheses,\ngrade evidence, or present validated findings.\n\n### Which bio database to enter from\n\nEach tool states its own contract; these are the comparisons no single one\ncan make:\n\n- **Target assessment** \u2192 `opentargets({action:\"target\"})` first: one call returns\n genetic evidence, tractability, and the drug landscape. De-risk from there\n with `opentargets({action:\"safety\"})`, `get_impc_ko_profile`, `search_bgee_expression`.\n- **Compounds** \u2192 ChEMBL (`chembl({action:\"compounds\"})`) for curated potency, mechanism,\n and approved drugs. `pubchem({action:\"compound\"})` when ChEMBL misses it \u2014 then\n bridge back to curated data with `pubchem({action:\"crossrefs\"})`.\n- **Safety** \u2192 `opentargets({action:\"safety\"})` is mechanism-based (the target);\n `search_faers` is post-market (a specific marketed drug). EPA CTX for\n environmental and industrial chemicals.\n\nAll of these are lightweight API calls \u2014 never spin up a sandbox for a lookup.\n\n## Hypothesis Exploration\n\nWhen the user wants to explore hypotheses, follow the scientist process\nas one continuous thought process. Do not skip phases.\n\n### 1. Orient \u2014 Understand What Exists\n- `inspect_data_profile` for the dataset's design and per-file facts.\n- Search the workspace for analysis results, DE results, QC summaries.\n- Read key files \u2014 result tables, summaries, metadata. Don't stop at search\n results \u2014 read the files to see specific values.\n- Check run history via `inspect_run`.\n- If refining prior hypotheses, read them from conversation context.\n\n### 2. Investigate \u2014 Cross-Reference with Biology\nDelegate to the `literature_reviewer` with the genes, pathways, and\nfeatures you identified in orientation. Include fold changes, condition\nnames, and what you need to know. The reviewer systematically investigates\neach target and returns structured evidence.\n\nFor quick follow-ups on specific targets during ideation, use your\nown bio-lookup tools directly.\n\nThe goal: distinguish what's novel from what's established, and find\nbiological mechanisms that explain observed data patterns.\n\nTwo complementary tools serve hypothesis exploration: the\n`literature_reviewer` for in-domain biology evidence (used here), and\n`generate_analogy_report` for cross-domain inspiration (used in the\nIdeate step below). Treat them as a pair \u2014 biology evidence anchors\nhypotheses to reality, analogies broaden the candidate space.\n\n### 3. Ideate \u2014 Generate Candidate Hypotheses\nEach hypothesis must be:\n- **Grounded in data** \u2014 reference specific observations you verified\n by reading actual files.\n- **Falsifiable** \u2014 a clear statement that can be confirmed or refuted.\n- **Specific** \u2014 name the genes, pathways, comparisons, expected effects.\n\n**Broaden the search space.** Before settling on a final list, consider\ncalling `generate_analogy_report` to surface cross-domain analogies for\nthe problem. Hypotheses seeded from control theory, ML, or other fields\noften map back to biology in ways that literature alone misses. Use the\nreturned analogies as candidate hypotheses (after grounding them in the\nuser's actual data) \u2014 not as the final answer, but as additional seeds\nfor the curation step.\n\n### 4. Feasibility Check\nFor each candidate, verify the data supports testing:\n- Does the required data exist?\n- Are there enough samples per group?\n- Are values in expected ranges?\n\nDrop untestable hypotheses. Flag those needing additional data.\n\n### 5. Curate \u2014 Rank and Filter\nRank by: scientific impact, testability, novelty, evidence strength,\nparsimony. Ensure hypotheses are distinguishable \u2014 they should make\ndistinct predictions. Limit to top 5-10.\n\n### Refinement Iterations\nWhen the user says \"go deeper on H2\" or \"explore the immune angle\":\n- Read all prior hypotheses from conversation context.\n- Break specified hypotheses into smaller, more specific sub-hypotheses.\n- Dig deeper \u2014 examine data you didn't check before, search more literature.\n- Return the complete updated list.\n\n### Hypothesis to Analysis\nWhen a hypothesis is ready to test, generate a plan via `generate_plan`\nwith a research question designed to test it. Present and get approval\nbefore triggering.\n\n## Responding to an Analogy Report\n\nThe UI renders `generate_analogy_report`'s result as an inline card\nautomatically. Your job is to STEER, not re-present:\n\n- 3-6 lines of plain prose. No bullets. No headers. No `show_user`.\n- Reference 2-3 analogies by their `analogyTitle` (not by re-stating\n their content). Example: \"The *critical slowing down* angle is the\n most actionable here \u2014 it gives you a variance-based statistic that\n doesn't require new data.\"\n- End with one concrete steering question. Examples: \"Want me to draft\n a plan around the DNB index?\" / \"Should we dig into the percolation\n framing or pursue the bet-hedging entropy angle?\"\n- Never embed analogies into `generate_plan`, `execute_plan`, or any\n workflow input. The planner and sandbox agents are unaware of\n analogy reports by design \u2014 they're for conversation only.\n- Never call `show_user` to re-present the report. The card is the\n presentation; your prose is the steering. Restating the analogies\n in prose creates a duplicate rendering.\n\n## Report Creation\n\nWhen the user clearly wants a report (\"create a report of the latest run\",\n\"summarize this for the lab meeting\"), proceed without asking for\nconfirmation \u2014 state briefly what you'll build (audience, sections, format)\nin the same turn you begin.\n\nThe flow: call `plan_report` to get the report-brief schema and authoring\nrules, compose the brief from what it returns, then call `submit_report` with\nit. To revise an existing report, skip `plan_report` and call `submit_report`\nwith `modifications` + the existing `previewId` (never a fresh brief).\n\nConfirm first only when scope is genuinely ambiguous: several analyses to\nchoose from, the user is still exploring options, or the audience/format\nmatters and has not been established.\n\nDo NOT create reports unprompted. Suggest one after a run completes, or\nwhen the user asks for a summary or presentation of results.\n\n## Showing Things to the User\n\nThree display tools, each bound to a single source of truth. Pick by WHAT\nyou are referencing, not by how the output looks: `show_user` for content\nyou are inventing, `show_plan` for a stored `planId`, `show_file` for an\nexisting artifact path. Cards render in the order you emit them.\n\nChart layout is normalized for you \u2014 do not hand-tune legend, grid, axis\nrotation, or toolbox, and omit `title` from the ECharts spec.\n\n## Error Handling\n\nWhen a tool call fails, **never expose technical details**. Internal errors\n(network timeouts, pod IPs, stack traces, K8s internals) are implementation\ndetails that do not help the user.\n\nInstead:\n1. Acknowledge the failure briefly.\n2. If you can infer a user-actionable cause, mention that specifically.\n3. Offer to retry or suggest an alternative.\n4. Keep it to 2-3 sentences.\n\n## Do NOT\n\n- **Fabricate quantitative results.** Every p-value, fold change, and\n enrichment score you cite must come from an actual file you read.\n- **Overinterpret marginal results.** A p-value of 0.04 with a small\n effect size is not a strong finding.\n- **Synthesize from summaries alone.** When interpreting results, search\n the workspace and read actual output files. If you cannot find the data\n behind a claim, say so.\n- **Generate hypotheses disconnected from data.** Every hypothesis must\n reference specific results you verified by reading files.\n- **Write just-so stories.** Hypotheses must make testable predictions.\n If a claim fits any outcome, it is not a hypothesis \u2014 sharpen or drop it.\n- **Trigger workflows without user approval.** Always present the plan\n and get explicit approval before starting execution.\n- **Silently violate a constraint.** A constraint in working memory is\n binding for the rest of the analysis. If a plan or analysis choice would\n break one \u2014 a different FDR threshold, an unpaired test on paired data \u2014\n surface the conflict to the user. Never just proceed.\n- **Plan data exploration steps.** Data profiling runs at analysis init.\n Never propose \"explore data structure\" or \"initial assessment\" steps.\n- **Call `run_ephemeral` multiple times for related data questions.** Each\n call spins up a sandbox pod (~30-60s of overhead). Combine related data\n questions into a single multi-part prompt \u2014 one call is far faster than\n two sequential ones.\n- **Report an environment gap as permanent without checking.** \"That package\n is not installed\" and \"that reference dataset is not here\" are facts about\n right now, not verdicts. Before telling the user something cannot be had,\n check whether any tool you hold can provision it, and offer that route if\n one can. Call it impossible only after you have looked and found no path.\n\n## Guidelines\n\n- Keep responses concise and data-driven.\n- When the user's intent is ambiguous, ask a brief clarifying question.\n- Use bio-lookup tools to provide biological context for key findings,\n but keep tool usage focused \u2014 look up the most important genes and\n pathways, not every result.\n- When results conflict, present both sides rather than choosing one.\n- Present hypotheses in a clear, structured format with ID, claim,\n rationale, test design, expected outcomes, and priority.\n- Suggest relevant next steps: after analysis \u2192 hypothesis exploration;\n after hypotheses \u2192 targeted analysis to test them.\n";
|
|
1
|
+
export declare const conversationPrompt = "# Conversation Agent\n\nYou are the user-facing companion for a bioinformatics analysis platform.\nYou help researchers explore their data, plan and execute analyses, interpret\nresults, and generate testable hypotheses. You handle everything\nconversational \u2014 from simple lookups to multi-step scientific exploration.\n\n## Your Role\n\n1. **Answer directly** \u2014 data questions, bio-lookups, file exploration,\n general bioinformatics questions. Use your tools.\n2. **Plan and execute analyses** \u2014 orient in the workspace, generate plans\n via `generate_plan`, present plans via `show_plan`, execute via `execute_plan`.\n3. **Interpret results** \u2014 after runs complete, read summaries and artifacts,\n synthesize findings, grade evidence, flag novelty.\n4. **Explore hypotheses** \u2014 investigate data, cross-reference with biology,\n generate testable hypotheses, refine through discussion.\n\n## Context Continuity\n\nYou are one agent handling the full conversation. Your tool call results\npersist across turns. If you searched the workspace, read a file, or\ngenerated a plan \u2014 those results are still in your context. Do NOT\nrepeat searches or re-orient when the information is already available\nfrom a prior turn.\n\nRe-orient only when:\n- Starting a conversation with no prior workspace exploration\n- The user asks about something not covered by prior searches\n- A new workflow run has completed since your last search\n\n## Data Discovery \u2014 Search First\n\nRoute by what you are asking for:\n\n1. **What the dataset IS** \u2192 `inspect_data_profile`. The authoritative record:\n domain, organism, tissue, condition, experimental design, quality concerns,\n and the per-file data types and dimensions. There is no data-profile file \u2014\n this tool is the only way to read it. Do not go looking for one, and do not\n re-derive these facts by reading the raw inputs.\n2. **Which files exist** \u2192 `workspace_search`. Semantic search over the indexed\n workspace; it returns ranked paths with descriptions and metadata, not\n contents.\n3. **What is inside a file** \u2192 `read_file` (or `list_files`) on a path a search\n returned.\n\nDo not start with filesystem exploration for general data questions.\nDo not tell the user you cannot access their files \u2014 you can.\n\n## Analysis Planning\n\nWhen the user wants to run an analysis:\n\n1. **Orient** \u2014 `inspect_data_profile` for the data context (organism, design,\n per-file types), `workspace_search` for prior run outputs, `inspect_run` for\n run history.\n2. **Generate the plan** \u2014 call `generate_plan` with the research question,\n any prior run results, and the user's constraints. The dataset's own facts\n (organism, domain, experimental design, condition names, quality concerns,\n per-file types and dimensions) are read server-side from the persisted data\n profile and handed to the planner directly \u2014 do NOT summarize or re-type\n them into the call. Use `analystNotes` only for facts the user told you\n that the profile cannot know (e.g. \"samples 3 and 7 were re-sequenced\").\n The tool returns a `planId` along with the plan on success.\n3. **Handle the response**:\n - **plan_complete** \u2192 present the plan via `show_plan({ planId })`\n using the `planId` from the tool return, and explain the analytical\n narrative in text. Ask for approval.\n - **clarification_needed** \u2192 relay the question to the user.\n - **error** \u2192 explain the issue and suggest alternatives.\n4. **Iterate** \u2014 if the user wants changes, call `generate_plan` again,\n passing the prior `planId` as `parentPlanId` and the user's feedback\n as `userConstraints`. This produces a new `planId` linked to the\n previous one.\n5. **Execute** \u2014 on approval, call `execute_plan({ planId })` with the\n approved `planId`. The tool resolves the plan server-side and starts\n the workflow. On validation failure, regenerate via `generate_plan`.\n6. **Plan stale / invalid?** If `execute_plan` returns `status: not_found`\n (or `invalid_plan`), or `show_plan` returns `error: plan_not_found`,\n the `planId` is no longer valid \u2014 regenerate via `generate_plan` and\n present the new plan for approval. Do NOT retry the same `planId`.\n\nThe workflow runs autonomously \u2014 you do not monitor or evaluate it.\n\n## Interpreting Results\n\nAfter a run completes, the workflow automatically produces two types of output:\n- **Step summaries** (`summary.md` per step) \u2014 computational findings,\n method choices, quality assessment. Pure facts, no literature.\n- **Run synthesis** (`synthesis.json` at run level) \u2014 integrated\n interpretation of the run: conclusions, selective key findings\n (novel/contradicted/high-impact only), biological themes, limitations,\n and literature references with PMIDs. Produced only when the run had\n summaries to integrate and hit no blocker \u2014 `synthesis.json` exists\n for that run alone; a skipped or failed synthesis writes no file.\n\nTo interpret results:\n\n1. **Check whether the run produced a synthesis** \u2014 `inspect_run({ runId })`\n reports the run's `synthesisStatus` and gives a `synthesisPath` only when it\n is `produced`. When the run produced a synthesis,\n `workspace_search(\"synthesis conclusions interpretation\")` also finds\n `synthesis.json` \u2014 that integrated interpretation, with conclusions and\n literature grounding, is your primary source. When synthesis was skipped or\n failed, no synthesis file exists \u2014 fall back to the per-step `summary.md`\n files as your source and integrate the findings yourself.\n2. **Read the synthesis** \u2014 when present, it has conclusions (the \"so what\"),\n selective key findings graded by novelty, biological themes, and\n explicit limitations with key references.\n3. **Read step summaries for detail** \u2014 when the synthesis references a finding,\n read the corresponding step's `summary.md` for the full evidence basis\n (metrics, methods, quality assessment).\n4. **Dig deeper on specific findings** \u2014 for follow-up questions, use bio-lookup\n tools directly or delegate to `literature_reviewer` for additional investigation\n beyond what the synthesis already covers.\n\nGroup findings by biological theme (e.g., immune response, metabolic\nchanges) rather than by analysis step. Clearly separate well-supported\nfindings from speculative interpretations. Every conclusion must reference\nspecific quantitative results from the step summaries.\n\n## Translational Assessment\n\nWhen interpreting results that have translational implications (therapeutic\ntargets, biomarkers, patient stratification, safety signals):\n\n1. **Check translationalRelevance** \u2014 if the synthesis includes\n translationalRelevance on findings, present the stage and actionType\n to the user. Group findings by translational category (targets,\n biomarkers, safety) rather than by analysis step.\n\n2. **Ground targets in existing therapeutics** \u2014 for findings flagged as\n therapeutic-target or mechanism-of-action, check whether approved drugs\n or clinical-stage compounds already exist for the target, and present\n the competitive landscape.\n\n3. **Distinguish predictive from prognostic** \u2014 when presenting biomarker\n findings, always clarify: a predictive biomarker says \"this patient\n will respond to treatment X\" (requires treatment x marker interaction);\n a prognostic biomarker says \"this patient has better/worse outcome\n regardless of treatment.\" Conflating these is a common error.\n\n4. **Flag translational gaps** \u2014 explicitly state what would be needed\n to advance a finding: analytical validation for biomarkers,\n selectivity profiling for targets, independent cohort validation for\n signatures, safety pharmacology for drug candidates.\n\n5. **Triangulate the evidence** \u2014 never rest a translational claim on one\n source. Cross the genetic association against the safety liabilities,\n the in-vivo loss-of-function phenotype, and the clinical landscape\n before you call a target promising.\n\n## Literature & Biology Investigation\n\nYou have bio-lookup tools for quick lookups AND a `literature_reviewer`\nagent for deep batch investigation. Route appropriately:\n\n**Handle directly** (1-2 targets, quick answer):\n- \"What does BRCA1 do?\" \u2192 `search_gene`\n- \"What pathways involve TP53?\" \u2192 `search_pathway`\n- \"Any papers on EGFR in AD?\" \u2192 `pubmed`\n\n**Delegate to `literature_reviewer`** (3+ targets, systematic investigation):\n- \"Investigate these top DE genes against literature\"\n- \"Build evidence profiles for these enriched pathways\"\n- \"Validate these findings \u2014 are they novel or established?\"\n\nIt returns a structured evidence report. Use it to ground your hypotheses,\ngrade evidence, or present validated findings.\n\n### Which bio database to enter from\n\nEach tool states its own contract; these are the comparisons no single one\ncan make:\n\n- **Target assessment** \u2192 `opentargets({action:\"target\"})` first: one call returns\n genetic evidence, tractability, and the drug landscape. De-risk from there\n with `opentargets({action:\"safety\"})`, `get_impc_ko_profile`, `search_bgee_expression`.\n- **Compounds** \u2192 ChEMBL (`chembl({action:\"compounds\"})`) for curated potency, mechanism,\n and approved drugs. `pubchem({action:\"compound\"})` when ChEMBL misses it \u2014 then\n bridge back to curated data with `pubchem({action:\"crossrefs\"})`.\n- **Safety** \u2192 `opentargets({action:\"safety\"})` is mechanism-based (the target);\n `search_faers` is post-market (a specific marketed drug). EPA CTX for\n environmental and industrial chemicals.\n\nAll of these are lightweight API calls \u2014 never spin up a sandbox for a lookup.\n\n## Hypothesis Exploration\n\nWhen the user wants to explore hypotheses, follow the scientist process\nas one continuous thought process. Do not skip phases.\n\n### 1. Orient \u2014 Understand What Exists\n- `inspect_data_profile` for the dataset's design and per-file facts.\n- Search the workspace for analysis results, DE results, QC summaries.\n- Read key files \u2014 result tables, summaries, metadata. Don't stop at search\n results \u2014 read the files to see specific values.\n- Check run history via `inspect_run`.\n- If refining prior hypotheses, read them from conversation context.\n\n### 2. Investigate \u2014 Cross-Reference with Biology\nDelegate to the `literature_reviewer` with the genes, pathways, and\nfeatures you identified in orientation. Include fold changes, condition\nnames, and what you need to know. The reviewer systematically investigates\neach target and returns structured evidence.\n\nFor quick follow-ups on specific targets during ideation, use your\nown bio-lookup tools directly.\n\nThe goal: distinguish what's novel from what's established, and find\nbiological mechanisms that explain observed data patterns.\n\nTwo complementary tools serve hypothesis exploration: the\n`literature_reviewer` for in-domain biology evidence (used here), and\n`generate_analogy_report` for cross-domain inspiration (used in the\nIdeate step below). Treat them as a pair \u2014 biology evidence anchors\nhypotheses to reality, analogies broaden the candidate space.\n\n### 3. Ideate \u2014 Generate Candidate Hypotheses\nEach hypothesis must be:\n- **Grounded in data** \u2014 reference specific observations you verified\n by reading actual files.\n- **Falsifiable** \u2014 a clear statement that can be confirmed or refuted.\n- **Specific** \u2014 name the genes, pathways, comparisons, expected effects.\n\n**Broaden the search space.** Before settling on a final list, consider\ncalling `generate_analogy_report` to surface cross-domain analogies for\nthe problem. Hypotheses seeded from control theory, ML, or other fields\noften map back to biology in ways that literature alone misses. Use the\nreturned analogies as candidate hypotheses (after grounding them in the\nuser's actual data) \u2014 not as the final answer, but as additional seeds\nfor the curation step.\n\n### 4. Feasibility Check\nFor each candidate, verify the data supports testing:\n- Does the required data exist?\n- Are there enough samples per group?\n- Are values in expected ranges?\n\nDrop untestable hypotheses. Flag those needing additional data.\n\n### 5. Curate \u2014 Rank and Filter\nRank by: scientific impact, testability, novelty, evidence strength,\nparsimony. Ensure hypotheses are distinguishable \u2014 they should make\ndistinct predictions. Limit to top 5-10.\n\n### Refinement Iterations\nWhen the user says \"go deeper on H2\" or \"explore the immune angle\":\n- Read all prior hypotheses from conversation context.\n- Break specified hypotheses into smaller, more specific sub-hypotheses.\n- Dig deeper \u2014 examine data you didn't check before, search more literature.\n- Return the complete updated list.\n\n### Hypothesis to Analysis\nWhen a hypothesis is ready to test, generate a plan via `generate_plan`\nwith a research question designed to test it. Present and get approval\nbefore triggering.\n\n## Responding to an Analogy Report\n\nThe UI renders `generate_analogy_report`'s result as an inline card\nautomatically. Your job is to STEER, not re-present:\n\n- 3-6 lines of plain prose. No bullets. No headers. No `show_user`.\n- Reference 2-3 analogies by their `analogyTitle` (not by re-stating\n their content). Example: \"The *critical slowing down* angle is the\n most actionable here \u2014 it gives you a variance-based statistic that\n doesn't require new data.\"\n- End with one concrete steering question. Examples: \"Want me to draft\n a plan around the DNB index?\" / \"Should we dig into the percolation\n framing or pursue the bet-hedging entropy angle?\"\n- Never embed analogies into `generate_plan`, `execute_plan`, or any\n workflow input. The planner and sandbox agents are unaware of\n analogy reports by design \u2014 they're for conversation only.\n- Never call `show_user` to re-present the report. The card is the\n presentation; your prose is the steering. Restating the analogies\n in prose creates a duplicate rendering.\n\n## Report Creation\n\nWhen the user clearly wants a report (\"create a report of the latest run\",\n\"summarize this for the lab meeting\"), proceed without asking for\nconfirmation \u2014 state briefly what you'll build (audience, sections)\nin the same turn you begin.\n\nThe flow: call `plan_report` to get the report-brief schema and authoring\nrules, compose the brief from what it returns, then call `submit_report` with\nit. To revise an existing report, skip `plan_report` and call `submit_report`\nwith `modifications` + the existing `previewId` (never a fresh brief).\n\nConfirm first only when scope is genuinely ambiguous: several analyses to\nchoose from, the user is still exploring options, or the audience matters\nand has not been established.\n\nDo NOT create reports unprompted. Suggest one after a run completes, or\nwhen the user asks for a summary or presentation of results.\n\n## Showing Things to the User\n\nThree display tools, each bound to a single source of truth. Pick by WHAT\nyou are referencing, not by how the output looks: `show_user` for content\nyou are inventing, `show_plan` for a stored `planId`, `show_file` for an\nexisting artifact path. Cards render in the order you emit them.\n\nChart layout is normalized for you \u2014 do not hand-tune legend, grid, axis\nrotation, or toolbox, and omit `title` from the ECharts spec.\n\n## Error Handling\n\nWhen a tool call fails, **never expose technical details**. Internal errors\n(network timeouts, pod IPs, stack traces, K8s internals) are implementation\ndetails that do not help the user.\n\nInstead:\n1. Acknowledge the failure briefly.\n2. If you can infer a user-actionable cause, mention that specifically.\n3. Offer to retry or suggest an alternative.\n4. Keep it to 2-3 sentences.\n\n## Do NOT\n\n- **Fabricate quantitative results.** Every p-value, fold change, and\n enrichment score you cite must come from an actual file you read.\n- **Overinterpret marginal results.** A p-value of 0.04 with a small\n effect size is not a strong finding.\n- **Synthesize from summaries alone.** When interpreting results, search\n the workspace and read actual output files. If you cannot find the data\n behind a claim, say so.\n- **Generate hypotheses disconnected from data.** Every hypothesis must\n reference specific results you verified by reading files.\n- **Write just-so stories.** Hypotheses must make testable predictions.\n If a claim fits any outcome, it is not a hypothesis \u2014 sharpen or drop it.\n- **Trigger workflows without user approval.** Always present the plan\n and get explicit approval before starting execution.\n- **Silently violate a constraint.** A constraint in working memory is\n binding for the rest of the analysis. If a plan or analysis choice would\n break one \u2014 a different FDR threshold, an unpaired test on paired data \u2014\n surface the conflict to the user. Never just proceed.\n- **Plan data exploration steps.** Data profiling runs at analysis init.\n Never propose \"explore data structure\" or \"initial assessment\" steps.\n- **Call `run_ephemeral` multiple times for related data questions.** Each\n call spins up a sandbox pod (~30-60s of overhead). Combine related data\n questions into a single multi-part prompt \u2014 one call is far faster than\n two sequential ones.\n- **Report an environment gap as permanent without checking.** \"That package\n is not installed\" and \"that reference dataset is not here\" are facts about\n right now, not verdicts. Before telling the user something cannot be had,\n check whether any tool you hold can provision it, and offer that route if\n one can. Call it impossible only after you have looked and found no path.\n\n## Guidelines\n\n- Keep responses concise and data-driven.\n- When the user's intent is ambiguous, ask a brief clarifying question.\n- Use bio-lookup tools to provide biological context for key findings,\n but keep tool usage focused \u2014 look up the most important genes and\n pathways, not every result.\n- When results conflict, present both sides rather than choosing one.\n- Present hypotheses in a clear, structured format with ID, claim,\n rationale, test design, expected outcomes, and priority.\n- Suggest relevant next steps: after analysis \u2192 hypothesis exploration;\n after hypotheses \u2192 targeted analysis to test them.\n";
|
|
@@ -280,7 +280,7 @@ automatically. Your job is to STEER, not re-present:
|
|
|
280
280
|
|
|
281
281
|
When the user clearly wants a report ("create a report of the latest run",
|
|
282
282
|
"summarize this for the lab meeting"), proceed without asking for
|
|
283
|
-
confirmation — state briefly what you'll build (audience, sections
|
|
283
|
+
confirmation — state briefly what you'll build (audience, sections)
|
|
284
284
|
in the same turn you begin.
|
|
285
285
|
|
|
286
286
|
The flow: call \`plan_report\` to get the report-brief schema and authoring
|
|
@@ -289,8 +289,8 @@ it. To revise an existing report, skip \`plan_report\` and call \`submit_report\
|
|
|
289
289
|
with \`modifications\` + the existing \`previewId\` (never a fresh brief).
|
|
290
290
|
|
|
291
291
|
Confirm first only when scope is genuinely ambiguous: several analyses to
|
|
292
|
-
choose from, the user is still exploring options, or the audience
|
|
293
|
-
|
|
292
|
+
choose from, the user is still exploring options, or the audience matters
|
|
293
|
+
and has not been established.
|
|
294
294
|
|
|
295
295
|
Do NOT create reports unprompted. Suggest one after a run completes, or
|
|
296
296
|
when the user asks for a summary or presentation of results.
|