@inflexa-ai/harness 0.13.0 → 0.14.1
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/conversation-agent.js +23 -16
- package/dist/agents/sandbox/cheminformatics-agent.js +1 -1
- package/dist/agents/sandbox/chromatin-agent.js +1 -1
- package/dist/agents/sandbox/dna-methylation-agent.js +1 -1
- package/dist/agents/sandbox/drug-repurposing-agent.js +5 -10
- package/dist/agents/sandbox/enrichment-agent.js +1 -1
- package/dist/agents/sandbox/genomic-variant-agent.js +1 -11
- package/dist/agents/sandbox/immune-profiling-agent.js +1 -1
- package/dist/agents/sandbox/multi-omics-integration-agent.js +1 -1
- package/dist/agents/sandbox/network-agent.js +1 -1
- package/dist/agents/sandbox/pkpd-clinical-response-agent.js +3 -6
- package/dist/agents/sandbox/scientific-executor.js +1 -1
- package/dist/agents/sandbox/shared.js +7 -13
- package/dist/agents/sandbox/translational-safety-agent.js +4 -7
- package/dist/agents/sandbox/types.d.ts +5 -5
- package/dist/agents/sandbox/types.js +4 -4
- package/dist/app/chat-turn.js +4 -1
- package/dist/app/message-assembly.d.ts +5 -2
- package/dist/app/message-assembly.js +7 -2
- package/dist/app/run-activity.d.ts +13 -0
- package/dist/app/run-activity.js +51 -0
- package/dist/contracts/target-dossier.d.ts +10 -10
- package/dist/execution/report-runner.js +1 -1
- package/dist/execution/run-event-parts.d.ts +44 -0
- package/dist/execution/run-event-parts.js +0 -0
- package/dist/execution/run-event-stream.d.ts +91 -0
- package/dist/execution/run-event-stream.js +212 -0
- package/dist/execution/run-synthesis.js +1 -1
- package/dist/index.d.ts +5 -2
- package/dist/index.js +20 -1
- package/dist/input-sanitization.d.ts +14 -4
- package/dist/input-sanitization.js +27 -4
- package/dist/lib/storage.js +4 -1
- package/dist/loop/budget-exceeded.d.ts +18 -2
- package/dist/loop/budget-exceeded.js +49 -2
- package/dist/loop/run-agent.d.ts +22 -1
- package/dist/loop/run-agent.js +91 -25
- package/dist/loop/run-to-terminal.d.ts +22 -4
- package/dist/loop/run-to-terminal.js +20 -4
- package/dist/memory/ai-sdk-message-storage.d.ts +37 -4
- package/dist/memory/ai-sdk-message-storage.js +45 -4
- package/dist/memory/content-to-cortex.js +19 -3
- package/dist/memory/message-backfill.js +1 -1
- package/dist/memory/thread-history.js +34 -8
- package/dist/prompts/conversation.d.ts +1 -1
- package/dist/prompts/conversation.js +15 -6
- package/dist/prompts/literature-reviewer.d.ts +1 -1
- package/dist/prompts/literature-reviewer.js +16 -14
- package/dist/prompts/planner.js +15 -20
- package/dist/prompts/sandbox/drug-repurposing-agent.d.ts +1 -1
- package/dist/prompts/sandbox/drug-repurposing-agent.js +31 -22
- package/dist/prompts/sandbox/multi-omics-integration-agent.d.ts +1 -1
- package/dist/prompts/sandbox/multi-omics-integration-agent.js +9 -10
- package/dist/prompts/sandbox/pkpd-clinical-response-agent.d.ts +1 -1
- package/dist/prompts/sandbox/pkpd-clinical-response-agent.js +8 -7
- package/dist/prompts/sandbox/translational-safety-agent.d.ts +1 -1
- package/dist/prompts/sandbox/translational-safety-agent.js +20 -14
- package/dist/prompts/synthesis-agent.d.ts +1 -1
- package/dist/prompts/synthesis-agent.js +7 -7
- package/dist/providers/errors.d.ts +7 -0
- package/dist/providers/errors.js +88 -3
- package/dist/runtime/pools.d.ts +15 -1
- package/dist/runtime/pools.js +15 -1
- package/dist/sandbox/sandbox-step-translate.d.ts +15 -2
- package/dist/sandbox/sandbox-step-translate.js +17 -2
- package/dist/schemas/workflow-state.d.ts +2 -2
- package/dist/schemas/workflow-state.js +2 -2
- package/dist/state/data-profile.d.ts +53 -1
- package/dist/state/data-profile.js +66 -6
- package/dist/state/index.d.ts +3 -3
- package/dist/state/index.js +2 -2
- package/dist/state/init.js +37 -2
- package/dist/state/runs.d.ts +39 -0
- package/dist/state/runs.js +103 -0
- package/dist/tasks/data-profile-activity.d.ts +87 -0
- package/dist/tasks/data-profile-activity.js +56 -0
- package/dist/tasks/data-profile.js +66 -4
- package/dist/tools/bio/chembl.d.ts +1 -1
- package/dist/tools/bio/chembl.js +51 -54
- package/dist/tools/bio/comptox.js +35 -33
- package/dist/tools/bio/drug-gene-interactions.d.ts +76 -0
- package/dist/tools/bio/drug-gene-interactions.js +175 -0
- package/dist/tools/bio/gene-disease-evidence.d.ts +61 -0
- package/dist/tools/bio/gene-disease-evidence.js +187 -0
- package/dist/tools/bio/gene-preclinical-profile.d.ts +58 -0
- package/dist/tools/bio/gene-preclinical-profile.js +125 -0
- package/dist/tools/bio/index.d.ts +23 -16
- package/dist/tools/bio/index.js +29 -22
- package/dist/tools/bio/keys.d.ts +16 -9
- package/dist/tools/bio/keys.js +22 -13
- package/dist/tools/bio/lookup-annotation.d.ts +48 -0
- package/dist/tools/bio/lookup-annotation.js +173 -0
- package/dist/tools/bio/opentargets.d.ts +7 -11
- package/dist/tools/bio/opentargets.js +24 -35
- package/dist/tools/bio/pubchem.js +6 -4
- package/dist/tools/bio/pubmed.d.ts +15 -7
- package/dist/tools/bio/pubmed.js +79 -20
- package/dist/tools/bio/search-clinical-trials.d.ts +20 -1
- package/dist/tools/bio/search-clinical-trials.js +26 -5
- package/dist/tools/bio/search-faers.js +9 -3
- package/dist/tools/bio/search-gene.d.ts +0 -1
- package/dist/tools/bio/search-gene.js +4 -5
- package/dist/tools/bio/search-geo-datasets.js +10 -4
- package/dist/tools/bio/search-interactions.d.ts +8 -4
- package/dist/tools/bio/search-interactions.js +28 -12
- package/dist/tools/bio/target-safety.d.ts +59 -0
- package/dist/tools/bio/target-safety.js +191 -0
- package/dist/tools/execute-analysis.d.ts +1 -0
- package/dist/tools/execute-analysis.js +5 -5
- package/dist/tools/lib/dgidb-client.d.ts +49 -0
- package/dist/tools/lib/dgidb-client.js +168 -0
- package/dist/tools/lib/disgenet-client.d.ts +56 -0
- package/dist/tools/lib/disgenet-client.js +59 -0
- package/dist/tools/lib/drugbank-client.d.ts +83 -0
- package/dist/tools/lib/drugbank-client.js +95 -0
- package/dist/tools/lib/ensembl-client.d.ts +4 -1
- package/dist/tools/lib/ensembl-client.js +5 -3
- package/dist/tools/lib/gwas-catalog-client.d.ts +60 -0
- package/dist/tools/lib/gwas-catalog-client.js +136 -0
- package/dist/tools/lib/pharmgkb-client.d.ts +22 -0
- package/dist/tools/lib/pharmgkb-client.js +32 -0
- package/dist/tools/lib/pubmed-client.d.ts +57 -4
- package/dist/tools/lib/pubmed-client.js +57 -3
- package/dist/tools/lib/string-client.d.ts +25 -4
- package/dist/tools/lib/string-client.js +22 -5
- package/dist/tools/research/generate-plan.d.ts +16 -9
- package/dist/tools/research/generate-plan.js +405 -85
- package/dist/tools/research/inspect-data-profile.d.ts +31 -6
- package/dist/tools/research/inspect-data-profile.js +23 -7
- package/dist/tools/research/inspect-run.d.ts +33 -15
- package/dist/tools/research/inspect-run.js +155 -66
- package/dist/tools/research/literature-reviewer.js +7 -11
- package/dist/workflows/execute-analysis.d.ts +63 -0
- package/dist/workflows/execute-analysis.js +73 -2
- package/dist/workflows/sandbox-step.js +1 -11
- package/dist/workflows/target-assessment/decisions/index.d.ts +1 -1
- package/dist/workflows/target-assessment/phase4-assemble.d.ts +1 -1
- package/dist/workflows/target-assessment/steps/phase2-aggregate.d.ts +2 -2
- package/dist/workflows/target-assessment/steps/phase3-aggregate.d.ts +1 -1
- package/package.json +1 -1
- package/dist/tools/bio/check-safety-panel.d.ts +0 -35
- package/dist/tools/bio/check-safety-panel.js +0 -76
- package/dist/tools/bio/get-impc-ko-profile.d.ts +0 -9
- package/dist/tools/bio/get-impc-ko-profile.js +0 -23
- package/dist/tools/bio/lookup-go-term.d.ts +0 -31
- package/dist/tools/bio/lookup-go-term.js +0 -116
- package/dist/tools/bio/search-bgee-expression.d.ts +0 -10
- package/dist/tools/bio/search-bgee-expression.js +0 -30
- package/dist/tools/bio/search-clinvar.d.ts +0 -13
- package/dist/tools/bio/search-clinvar.js +0 -28
- package/dist/tools/bio/search-dgidb.d.ts +0 -53
- package/dist/tools/bio/search-dgidb.js +0 -213
- package/dist/tools/bio/search-disgenet.d.ts +0 -30
- package/dist/tools/bio/search-disgenet.js +0 -84
- package/dist/tools/bio/search-drugbank.d.ts +0 -38
- package/dist/tools/bio/search-drugbank.js +0 -109
- package/dist/tools/bio/search-gwas-catalog.d.ts +0 -33
- package/dist/tools/bio/search-gwas-catalog.js +0 -172
- package/dist/tools/bio/search-pathway.d.ts +0 -12
- package/dist/tools/bio/search-pathway.js +0 -32
- package/dist/tools/bio/search-pharmgkb.d.ts +0 -17
- package/dist/tools/bio/search-pharmgkb.js +0 -52
package/dist/loop/run-agent.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type FinishReason } from "ai";
|
|
2
2
|
import type { AgentSession } from "../auth/types.js";
|
|
3
|
-
import type {
|
|
3
|
+
import type { Logger } from "../lib/logger.js";
|
|
4
|
+
import type { AgentChat, ChatRequest, PromptCachePolicy } from "../providers/types.js";
|
|
4
5
|
import { type AskApproval, type AskRequest } from "../tools/approval/contract.js";
|
|
5
6
|
import type { AgentDefinition, EmitFn, LoopMessage, RunStep } from "./types.js";
|
|
6
7
|
export interface AgentFinish {
|
|
@@ -34,6 +35,15 @@ export interface RunAgentOptions {
|
|
|
34
35
|
readonly runStep: RunStep;
|
|
35
36
|
readonly formatStepName?: StepNameFormatter;
|
|
36
37
|
readonly isFatalLoopError?: (err: unknown) => boolean;
|
|
38
|
+
/** Tool-selection policy for in-loop model calls. The cap wrap-up remains
|
|
39
|
+
* tool-less regardless. */
|
|
40
|
+
readonly toolChoice?: ChatRequest["toolChoice"];
|
|
41
|
+
/**
|
|
42
|
+
* Optional outcome predicate for loop-driving agents whose result is
|
|
43
|
+
* recorded by a tool into closure state. Checked immediately after each
|
|
44
|
+
* dispatch round, once every sibling tool result has been appended.
|
|
45
|
+
*/
|
|
46
|
+
readonly resolved?: () => boolean;
|
|
37
47
|
/**
|
|
38
48
|
* Prompt-cache policy for every LLM call this run makes. Defaults to
|
|
39
49
|
* `DEFAULT_PROMPT_CACHE` (5m) — an agent loop always re-sends its prefix, so
|
|
@@ -46,6 +56,17 @@ export interface RunAgentOptions {
|
|
|
46
56
|
* cache-write premium for a cache nothing ever reads back.
|
|
47
57
|
*/
|
|
48
58
|
readonly promptCache?: PromptCachePolicy;
|
|
59
|
+
/**
|
|
60
|
+
* Diagnostic sink for the run's own lifecycle. Optional because `runAgent` is
|
|
61
|
+
* called from tools, workflow bodies, and test rigs alike — silence is the
|
|
62
|
+
* correct behaviour for a caller that wires nothing, and the noop fallback
|
|
63
|
+
* gives it without every call site threading `?.`.
|
|
64
|
+
*
|
|
65
|
+
* Distinct from `emit`, which feeds a user-facing surface: every host filters
|
|
66
|
+
* sub-agent events off that surface by `callPath` depth, so a sub-agent's
|
|
67
|
+
* progress is emitted and then dropped. This is where it survives.
|
|
68
|
+
*/
|
|
69
|
+
readonly logger?: Logger;
|
|
49
70
|
}
|
|
50
71
|
export declare function runAgent(agent: AgentDefinition, initial: readonly LoopMessage[], session: AgentSession, opts: RunAgentOptions): Promise<RunAgentResult>;
|
|
51
72
|
export declare function finalText(messages: readonly LoopMessage[]): string;
|
package/dist/loop/run-agent.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { jsonSchema, tool as aiTool } from "ai";
|
|
2
|
+
import { stripNulCharacters } from "../input-sanitization.js";
|
|
3
|
+
import { createNoopLogger } from "../lib/console-logger.js";
|
|
2
4
|
import { hintForZodIssue, repairToolInput } from "../lib/zod-issues.js";
|
|
3
5
|
import { markInterruptedMessage, syntheticUserMessage } from "../memory/ai-sdk-message-storage.js";
|
|
4
6
|
import { classifyProviderError } from "../providers/errors.js";
|
|
@@ -16,7 +18,11 @@ export const DEFAULT_STEP_NAME_FORMATTER = {
|
|
|
16
18
|
export async function runAgent(agent, initial, session, opts) {
|
|
17
19
|
const { provider, signal, emit, runStep } = opts;
|
|
18
20
|
const formatStepName = opts.formatStepName ?? DEFAULT_STEP_NAME_FORMATTER;
|
|
19
|
-
const
|
|
21
|
+
const configuredFatalLoopError = opts.isFatalLoopError ?? (() => false);
|
|
22
|
+
// AbortError is control flow, not a tool failure. Always compose it with the
|
|
23
|
+
// host's fatal predicate so request cancellation cannot be converted into a
|
|
24
|
+
// model-visible error result and retried by the loop.
|
|
25
|
+
const isFatalLoopError = (err) => ((err instanceof Error || err instanceof DOMException) && err.name === "AbortError") || configuredFatalLoopError(err);
|
|
20
26
|
if (agent.tools.length > 0 && !provider.capabilities.toolCalling) {
|
|
21
27
|
throw new Error(`Provider/model cannot run tool-required agent "${agent.id}"`);
|
|
22
28
|
}
|
|
@@ -25,6 +31,11 @@ export async function runAgent(agent, initial, session, opts) {
|
|
|
25
31
|
agentId: session.provenance.agentId ?? agent.id,
|
|
26
32
|
callPath: session.provenance.callPath,
|
|
27
33
|
};
|
|
34
|
+
// Bound from the same `source` the emitted events carry: one derivation feeding
|
|
35
|
+
// both sinks, so a record and an event cannot disagree about who produced them.
|
|
36
|
+
// `callPath` rides as an array rather than a joined string — the queryable form;
|
|
37
|
+
// rendering `parent > child` is the sink's choice, not a published package's.
|
|
38
|
+
const log = (opts.logger ?? createNoopLogger()).named("loop").with({ agentId: source.agentId, callPath: source.callPath });
|
|
28
39
|
const toolsById = new Map(agent.tools.map((t) => [t.id, t]));
|
|
29
40
|
const toolDefs = Object.fromEntries(agent.tools.map((t) => [
|
|
30
41
|
t.id,
|
|
@@ -49,11 +60,23 @@ export async function runAgent(agent, initial, session, opts) {
|
|
|
49
60
|
});
|
|
50
61
|
let iterations = 0;
|
|
51
62
|
let truncationRecoveries = 0;
|
|
63
|
+
// Counted rather than listed: the finish record stays one bounded line for a run of
|
|
64
|
+
// any length, and "50 iterations, 49 tool calls, 47 of them errored" already separates
|
|
65
|
+
// a productive long run from a loop stuck retrying one failing call. The per-iteration
|
|
66
|
+
// `debug` records name the tools when that distinction is not enough.
|
|
67
|
+
let toolCallCount = 0;
|
|
68
|
+
let toolErrorCount = 0;
|
|
52
69
|
// Resolved once, not per iteration: an identical options object across every
|
|
53
70
|
// call is itself part of the cache contract — the request prefix has to be
|
|
54
71
|
// byte-identical to be read back.
|
|
55
72
|
const providerOptions = promptCacheProviderOptions(opts.promptCache ?? DEFAULT_PROMPT_CACHE);
|
|
56
73
|
const usage = {};
|
|
74
|
+
// Exactly one record per completed run — never one per iteration. That bound is
|
|
75
|
+
// what keeps the default level affordable for an agent that runs long; the
|
|
76
|
+
// per-iteration detail lives at `debug`, where paying per iteration is the point.
|
|
77
|
+
const logFinish = (level, reason, cappedOut) => {
|
|
78
|
+
log[level]("run finished", { iterations, reason, cappedOut, truncationRecoveries, toolCalls: toolCallCount, toolErrors: toolErrorCount, usage });
|
|
79
|
+
};
|
|
57
80
|
// The user said no. A subsequent model call would only let the agent argue
|
|
58
81
|
// with the decision, or spend a call acknowledging it; the denial tool result
|
|
59
82
|
// is itself what the surface renders, so the turn ends the moment a denial
|
|
@@ -62,14 +85,44 @@ export async function runAgent(agent, initial, session, opts) {
|
|
|
62
85
|
const stopOnDenial = async (i) => {
|
|
63
86
|
await emit({ type: "iteration", source, index: i, final: true });
|
|
64
87
|
recordAgentRun({ agentId: agent.id, iterations, cappedOut: false, usage });
|
|
88
|
+
logFinish("warn", "denied", false);
|
|
65
89
|
return { messages, finish: { reason: "denied", cappedOut: false, truncationRecoveries } };
|
|
66
90
|
};
|
|
91
|
+
/**
|
|
92
|
+
* Fold one dispatch round into the run's counters and the event sink, returning the
|
|
93
|
+
* tools whose results the model will read as errors.
|
|
94
|
+
*
|
|
95
|
+
* Shared by both dispatch paths so a truncated round and a normal one cannot drift
|
|
96
|
+
* in what they count. An error tool result is otherwise invisible to every sink —
|
|
97
|
+
* it is not a thrown failure, so nothing reports it and the loop simply feeds it
|
|
98
|
+
* back — and a run that ends badly is usually a run whose tool calls were failing.
|
|
99
|
+
*/
|
|
100
|
+
const settleRound = async (calls, results) => {
|
|
101
|
+
const errored = [];
|
|
102
|
+
for (let idx = 0; idx < calls.length; idx++) {
|
|
103
|
+
const tu = calls[idx];
|
|
104
|
+
const isError = isErrorOutput(results[idx]);
|
|
105
|
+
toolCallCount++;
|
|
106
|
+
if (isError) {
|
|
107
|
+
toolErrorCount++;
|
|
108
|
+
errored.push(tu.toolName);
|
|
109
|
+
}
|
|
110
|
+
await emit({ type: "tool-finished", source, toolUseId: tu.toolCallId, name: tu.toolName, isError });
|
|
111
|
+
}
|
|
112
|
+
return errored;
|
|
113
|
+
};
|
|
114
|
+
const stopOnResolved = async (i) => {
|
|
115
|
+
await emit({ type: "iteration", source, index: i, final: true });
|
|
116
|
+
recordAgentRun({ agentId: agent.id, iterations, cappedOut: false, usage });
|
|
117
|
+
return { messages, finish: { reason: "stop", cappedOut: false, truncationRecoveries } };
|
|
118
|
+
};
|
|
67
119
|
for (let i = 0; i < agent.maxIterations; i++) {
|
|
68
120
|
iterations = i + 1;
|
|
69
121
|
const request = {
|
|
70
122
|
system: agent.systemPrompt,
|
|
71
123
|
messages,
|
|
72
124
|
tools: toolDefs,
|
|
125
|
+
...(opts.toolChoice !== undefined ? { toolChoice: opts.toolChoice } : {}),
|
|
73
126
|
providerOptions,
|
|
74
127
|
};
|
|
75
128
|
const reply = await resultStep(runStep)(formatStepName.llm(i), () => provider.chat(request, session, signal));
|
|
@@ -93,6 +146,9 @@ export async function runAgent(agent, initial, session, opts) {
|
|
|
93
146
|
if (reply.finishReason === "length") {
|
|
94
147
|
truncationRecoveries++;
|
|
95
148
|
await emit({ type: "iteration", source, index: i, final: false });
|
|
149
|
+
// `tools` is what the model asked for; the trailing call was cut off at the
|
|
150
|
+
// output limit and is never dispatched, which the distinct message records.
|
|
151
|
+
log.debug("iteration truncated at output limit", { iteration: i, tools: toolCalls.map((t) => t.toolName), truncationRecoveries });
|
|
96
152
|
if (toolCalls.length === 0) {
|
|
97
153
|
// Stamped synthetic, not left as a bare `user` message: the wire format needs a user turn
|
|
98
154
|
// after a truncated assistant message, but this one is the loop's own nudge, and thread
|
|
@@ -107,45 +163,43 @@ export async function runAgent(agent, initial, session, opts) {
|
|
|
107
163
|
await emit({ type: "tool-started", source, toolUseId: tu.toolCallId, name: tu.toolName, input: tu.input });
|
|
108
164
|
}
|
|
109
165
|
const results = await dispatchTools(earlier, toolsById, toolCtx, isFatalLoopError, runStep, formatStepName.tool);
|
|
110
|
-
|
|
111
|
-
const tu = earlier[idx];
|
|
112
|
-
await emit({
|
|
113
|
-
type: "tool-finished",
|
|
114
|
-
source,
|
|
115
|
-
toolUseId: tu.toolCallId,
|
|
116
|
-
name: tu.toolName,
|
|
117
|
-
isError: isErrorOutput(results[idx]),
|
|
118
|
-
});
|
|
119
|
-
}
|
|
166
|
+
const errored = await settleRound(earlier, results);
|
|
120
167
|
results.push(errorResult(trailing, TRUNCATED_TOOL_USE_ERROR));
|
|
168
|
+
// The trailing call was never dispatched, but it reaches the model as an error
|
|
169
|
+
// result like any other — so it counts, or `toolErrors` reports a cleaner run
|
|
170
|
+
// than the model actually read. It gets no `tool-finished` event: no
|
|
171
|
+
// `tool-started` was emitted for it either, and the pair must stay balanced.
|
|
172
|
+
toolCallCount++;
|
|
173
|
+
toolErrorCount++;
|
|
174
|
+
errored.push(trailing.toolName);
|
|
175
|
+
log.debug("tool results returned errors", { iteration: i, tools: errored });
|
|
121
176
|
messages.push({ role: "tool", content: results });
|
|
122
177
|
if (hasDenial(results))
|
|
123
178
|
return stopOnDenial(i);
|
|
179
|
+
if (opts.resolved?.())
|
|
180
|
+
return stopOnResolved(i);
|
|
124
181
|
continue;
|
|
125
182
|
}
|
|
126
183
|
if (reply.finishReason !== "tool-calls") {
|
|
127
184
|
await emit({ type: "iteration", source, index: i, final: true });
|
|
128
185
|
recordAgentRun({ agentId: agent.id, iterations, cappedOut: false, usage });
|
|
186
|
+
logFinish("info", reply.finishReason, false);
|
|
129
187
|
return { messages, finish: { reason: reply.finishReason, cappedOut: false, truncationRecoveries } };
|
|
130
188
|
}
|
|
131
189
|
await emit({ type: "iteration", source, index: i, final: false });
|
|
190
|
+
log.debug("iteration", { iteration: i, tools: toolCalls.map((t) => t.toolName) });
|
|
132
191
|
for (const tu of toolCalls) {
|
|
133
192
|
await emit({ type: "tool-started", source, toolUseId: tu.toolCallId, name: tu.toolName, input: tu.input });
|
|
134
193
|
}
|
|
135
194
|
const results = await dispatchTools(toolCalls, toolsById, toolCtx, isFatalLoopError, runStep, formatStepName.tool);
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
type: "tool-finished",
|
|
140
|
-
source,
|
|
141
|
-
toolUseId: tu.toolCallId,
|
|
142
|
-
name: tu.toolName,
|
|
143
|
-
isError: isErrorOutput(results[idx]),
|
|
144
|
-
});
|
|
145
|
-
}
|
|
195
|
+
const errored = await settleRound(toolCalls, results);
|
|
196
|
+
if (errored.length > 0)
|
|
197
|
+
log.debug("tool results returned errors", { iteration: i, tools: errored });
|
|
146
198
|
messages.push({ role: "tool", content: results });
|
|
147
199
|
if (hasDenial(results))
|
|
148
200
|
return stopOnDenial(i);
|
|
201
|
+
if (opts.resolved?.())
|
|
202
|
+
return stopOnResolved(i);
|
|
149
203
|
}
|
|
150
204
|
// Cache defeater (known; not fixed here). Emptying the tool set changes the
|
|
151
205
|
// very front of the request prefix — tool definitions are cached ahead of
|
|
@@ -167,11 +221,13 @@ export async function runAgent(agent, initial, session, opts) {
|
|
|
167
221
|
markLastLoopAssistant(messages, initial.length);
|
|
168
222
|
await emit({ type: "iteration", source, index: agent.maxIterations, final: true });
|
|
169
223
|
recordAgentRun({ agentId: agent.id, iterations, cappedOut: true, usage });
|
|
224
|
+
logFinish("warn", "aborted", true);
|
|
170
225
|
return { messages, finish: { reason: "aborted", cappedOut: true, truncationRecoveries } };
|
|
171
226
|
}
|
|
172
227
|
messages.push(wrapUp.message);
|
|
173
228
|
await emit({ type: "iteration", source, index: agent.maxIterations, final: true });
|
|
174
229
|
recordAgentRun({ agentId: agent.id, iterations, cappedOut: true, usage });
|
|
230
|
+
logFinish("warn", "max_iterations", true);
|
|
175
231
|
return { messages, finish: { reason: "max_iterations", cappedOut: true, truncationRecoveries } };
|
|
176
232
|
}
|
|
177
233
|
function toolCallParts(message) {
|
|
@@ -257,8 +313,14 @@ async function execute(tu, tool, input, ctx, isFatalLoopError) {
|
|
|
257
313
|
return errorResult(tu, toolErrorContent(err));
|
|
258
314
|
}
|
|
259
315
|
}
|
|
316
|
+
/**
|
|
317
|
+
* Normalize a tool's return value to plain JSON, stripping NUL from every string
|
|
318
|
+
* it holds ({@link stripNulCharacters}). The reviver sees values, not keys — a
|
|
319
|
+
* NUL in an object KEY would still reach the row, but keys come from tool result
|
|
320
|
+
* types rather than scanned bytes, so nothing produces one.
|
|
321
|
+
*/
|
|
260
322
|
function jsonValue(value) {
|
|
261
|
-
return JSON.parse(JSON.stringify(value ?? null));
|
|
323
|
+
return JSON.parse(JSON.stringify(value ?? null), (_key, v) => (typeof v === "string" ? stripNulCharacters(v) : v));
|
|
262
324
|
}
|
|
263
325
|
function successResult(toolCall, value) {
|
|
264
326
|
return {
|
|
@@ -268,12 +330,15 @@ function successResult(toolCall, value) {
|
|
|
268
330
|
output: { type: "json", value: jsonValue(value) },
|
|
269
331
|
};
|
|
270
332
|
}
|
|
333
|
+
// NUL is stripped from the prose BEFORE it is serialized: past the stringify it
|
|
334
|
+
// is the six-character escape \u0000 — legitimate JSON text, and no longer
|
|
335
|
+
// distinguishable from an error that happens to quote that escape.
|
|
271
336
|
function toolErrorContent(value) {
|
|
272
337
|
if (isToolError(value)) {
|
|
273
|
-
return JSON.stringify({ error: value.error, retryable: value.retryable });
|
|
338
|
+
return JSON.stringify({ error: stripNulCharacters(value.error), retryable: value.retryable });
|
|
274
339
|
}
|
|
275
340
|
const { retryable } = classifyProviderError(value);
|
|
276
|
-
const error = value instanceof Error ? value.message : String(value);
|
|
341
|
+
const error = stripNulCharacters(value instanceof Error ? value.message : String(value));
|
|
277
342
|
return JSON.stringify({ error, retryable });
|
|
278
343
|
}
|
|
279
344
|
function errorResult(toolCall, content) {
|
|
@@ -281,7 +346,8 @@ function errorResult(toolCall, content) {
|
|
|
281
346
|
type: "tool-result",
|
|
282
347
|
toolCallId: toolCall.toolCallId,
|
|
283
348
|
toolName: toolCall.toolName,
|
|
284
|
-
|
|
349
|
+
// A thrown tool error routinely quotes stderr verbatim — as exposed to NUL as a success payload.
|
|
350
|
+
output: { type: "error-text", value: stripNulCharacters(content) },
|
|
285
351
|
};
|
|
286
352
|
}
|
|
287
353
|
function isAskRejected(err) {
|
|
@@ -22,14 +22,32 @@
|
|
|
22
22
|
*/
|
|
23
23
|
import type { AgentSession } from "../auth/types.js";
|
|
24
24
|
import type { Tool } from "../tools/define-tool.js";
|
|
25
|
-
import { type RunAgentOptions, type RunAgentResult } from "./run-agent.js";
|
|
25
|
+
import { type AgentFinish, type RunAgentOptions, type RunAgentResult } from "./run-agent.js";
|
|
26
26
|
import type { AgentDefinition, LoopMessage } from "./types.js";
|
|
27
27
|
/** Default salvage budget: one submit plus a validation-fix retry or two. */
|
|
28
28
|
export declare const DEFAULT_SALVAGE_ITERATIONS = 3;
|
|
29
|
+
/**
|
|
30
|
+
* What a salvage continuation was and how it went — present only when one ran.
|
|
31
|
+
*
|
|
32
|
+
* The two finishes answer different questions and a diagnostician needs both: a
|
|
33
|
+
* first run that ended `max_iterations` spent its whole budget failing to submit,
|
|
34
|
+
* while one that ended `stop` gave up on prose after a single turn. Those call for
|
|
35
|
+
* opposite responses, and the returned `RunAgentResult` carries only the second
|
|
36
|
+
* run's finish, which is the same for either.
|
|
37
|
+
*/
|
|
38
|
+
export interface SalvageRecord {
|
|
39
|
+
/** How the FIRST run ended — the reason a salvage was needed at all. */
|
|
40
|
+
readonly firstFinish: AgentFinish;
|
|
41
|
+
/** How the salvage continuation itself ended. */
|
|
42
|
+
readonly finish: AgentFinish;
|
|
43
|
+
}
|
|
44
|
+
/** A `runToTerminal` outcome: the driving run's result, plus the salvage account. */
|
|
45
|
+
export interface RunToTerminalResult extends RunAgentResult {
|
|
46
|
+
/** Null when the first run reached its terminal tool and no salvage was needed. */
|
|
47
|
+
readonly salvage: SalvageRecord | null;
|
|
48
|
+
}
|
|
29
49
|
/** Describes how to salvage a run that never reached its terminal tool. */
|
|
30
50
|
export interface TerminalSalvage {
|
|
31
|
-
/** True once the agent recorded its terminal outcome (reads the closure cell). */
|
|
32
|
-
readonly resolved: () => boolean;
|
|
33
51
|
/** Terminal tools offered on the salvage turn (submit / blocker / …). Must be
|
|
34
52
|
* the same instances the first run used — they close over the outcome cell. */
|
|
35
53
|
readonly tools: readonly Tool[];
|
|
@@ -43,4 +61,4 @@ export interface TerminalSalvage {
|
|
|
43
61
|
* Returns the salvage run's result when a salvage occurred (its message array
|
|
44
62
|
* already includes the first run's), otherwise the first run's result.
|
|
45
63
|
*/
|
|
46
|
-
export declare function runToTerminal(agent: AgentDefinition, initial: readonly LoopMessage[], session: AgentSession, opts: RunAgentOptions, salvage: TerminalSalvage): Promise<
|
|
64
|
+
export declare function runToTerminal(agent: AgentDefinition, initial: readonly LoopMessage[], session: AgentSession, opts: RunAgentOptions, salvage: TerminalSalvage): Promise<RunToTerminalResult>;
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
* not collide the continuation's `llm-*` / `tool-*` cache keys with the first
|
|
21
21
|
* run's — replaying them would silently return the first run's cached results.
|
|
22
22
|
*/
|
|
23
|
+
import { createNoopLogger } from "../lib/console-logger.js";
|
|
23
24
|
import { DEFAULT_STEP_NAME_FORMATTER, runAgent } from "./run-agent.js";
|
|
24
25
|
/** Default salvage budget: one submit plus a validation-fix retry or two. */
|
|
25
26
|
export const DEFAULT_SALVAGE_ITERATIONS = 3;
|
|
@@ -36,16 +37,31 @@ function salvageStepNames(base) {
|
|
|
36
37
|
*/
|
|
37
38
|
export async function runToTerminal(agent, initial, session, opts, salvage) {
|
|
38
39
|
const first = await runAgent(agent, initial, session, opts);
|
|
39
|
-
if (
|
|
40
|
-
return first;
|
|
40
|
+
if (opts.resolved?.() || opts.signal.aborted)
|
|
41
|
+
return { ...first, salvage: null };
|
|
42
|
+
const salvageBudget = salvage.maxIterations ?? DEFAULT_SALVAGE_ITERATIONS;
|
|
43
|
+
// Reported here rather than in `runAgent` because the loop cannot know it is being
|
|
44
|
+
// salvaged: it sees an ordinary run with a small budget and a restricted tool set.
|
|
45
|
+
// Only this wrapper holds the fact that a first attempt ended without its outcome.
|
|
46
|
+
// The first run's finish rides along because it is the whole diagnosis of WHY a
|
|
47
|
+
// salvage was needed, and it is the field the second run's result overwrites.
|
|
48
|
+
(opts.logger ?? createNoopLogger()).named("loop").warn("salvaging a run that never reached its terminal tool", {
|
|
49
|
+
agentId: agent.id,
|
|
50
|
+
callPath: session.provenance.callPath,
|
|
51
|
+
firstFinishReason: first.finish.reason,
|
|
52
|
+
firstCappedOut: first.finish.cappedOut,
|
|
53
|
+
salvageTools: salvage.tools.map((t) => t.id),
|
|
54
|
+
salvageMaxIterations: salvageBudget,
|
|
55
|
+
});
|
|
41
56
|
const salvageAgent = {
|
|
42
57
|
...agent,
|
|
43
58
|
tools: [...salvage.tools],
|
|
44
|
-
maxIterations:
|
|
59
|
+
maxIterations: salvageBudget,
|
|
45
60
|
};
|
|
46
61
|
const salvageOpts = {
|
|
47
62
|
...opts,
|
|
48
63
|
formatStepName: salvageStepNames(opts.formatStepName ?? DEFAULT_STEP_NAME_FORMATTER),
|
|
49
64
|
};
|
|
50
|
-
|
|
65
|
+
const salvaged = await runAgent(salvageAgent, [...first.messages, { role: "user", content: salvage.nudge }], session, salvageOpts);
|
|
66
|
+
return { ...salvaged, salvage: { firstFinish: first.finish, finish: salvaged.finish } };
|
|
51
67
|
}
|
|
@@ -20,8 +20,19 @@ export declare function parseStoredMessageEnvelope(value: unknown, identity: str
|
|
|
20
20
|
* harness fact from the loop, through `appendTurn`, into a row.
|
|
21
21
|
*/
|
|
22
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. */
|
|
23
|
+
/** The {@link HARNESS_PROVIDER_NAMESPACE} key marking a message the loop or host synthesized rather than one a human sent. */
|
|
24
24
|
export declare const SYNTHETIC_MESSAGE_KEY = "synthetic";
|
|
25
|
+
/**
|
|
26
|
+
* The {@link HARNESS_PROVIDER_NAMESPACE} key marking a synthetic message that is a RECORD OF FACT
|
|
27
|
+
* the reader is meant to see, as opposed to loop machinery they are not.
|
|
28
|
+
*
|
|
29
|
+
* Always accompanies {@link SYNTHETIC_MESSAGE_KEY} — a record is synthetic in the sense that matters
|
|
30
|
+
* to every turn-boundary reader, and must never open a turn. This key answers the one question
|
|
31
|
+
* `synthetic` alone cannot: whether the message should be RENDERED. The loop's truncation nudge must
|
|
32
|
+
* not be (it would show words nobody typed); an embedder's out-of-band record must be (it is the
|
|
33
|
+
* only trace in the conversation that the work happened at all).
|
|
34
|
+
*/
|
|
35
|
+
export declare const SYNTHETIC_RECORD_KEY = "syntheticRecord";
|
|
25
36
|
/** The {@link HARNESS_PROVIDER_NAMESPACE} key marking an assistant message whose production a client abort cut off. */
|
|
26
37
|
export declare const INTERRUPTED_MESSAGE_KEY = "interrupted";
|
|
27
38
|
/**
|
|
@@ -39,18 +50,40 @@ export declare function markInterruptedMessage(message: ModelMessage): ModelMess
|
|
|
39
50
|
/** Whether a client abort cut off this message's production (see {@link markInterruptedMessage}); an absent key reads as not interrupted. */
|
|
40
51
|
export declare function isInterruptedMessage(message: ModelMessage): boolean;
|
|
41
52
|
/**
|
|
42
|
-
* Build a `user` message the
|
|
43
|
-
* truncated at its output-token limit.
|
|
53
|
+
* Build a `user` message the LOOP synthesized rather than a human typing it — today, the nudge that
|
|
54
|
+
* continues a reply the model truncated at its output-token limit.
|
|
44
55
|
*
|
|
45
56
|
* It has to carry the `user` role because the wire format requires a user turn after a truncated
|
|
46
57
|
* assistant message, but it is not user input, and the difference is load-bearing: a `user` message is
|
|
47
58
|
* what OPENS a conversation turn, so an unmarked synthetic one reads as a spurious turn boundary —
|
|
48
59
|
* splitting one turn into two for the token window and, worse, giving a tail-turn removal a cut point in
|
|
49
60
|
* the middle of a turn. Marking it is what lets {@link isSyntheticUserMessage} keep those readers honest.
|
|
61
|
+
*
|
|
62
|
+
* This one is loop MACHINERY and is not rendered: showing it would put words on screen nobody typed.
|
|
63
|
+
* An embedder recording out-of-band work — an analysis run's outcome — wants
|
|
64
|
+
* {@link syntheticRecordMessage} instead, which is equally non-turn-opening but IS displayed.
|
|
50
65
|
*/
|
|
51
66
|
export declare function syntheticUserMessage(text: string): ModelMessage;
|
|
52
|
-
/**
|
|
67
|
+
/**
|
|
68
|
+
* Build a record of out-of-band work for an embedder to append between turns — an analysis run's
|
|
69
|
+
* outcome, which the model should read on its next turn but which nobody said.
|
|
70
|
+
*
|
|
71
|
+
* Synthetic in every sense that matters to a turn-boundary reader ({@link isSyntheticUserMessage} is
|
|
72
|
+
* true of it), so it cannot split a turn for the token window nor hand a tail retraction a mid-turn
|
|
73
|
+
* cut point. It differs from {@link syntheticUserMessage} in exactly one respect, and it is the
|
|
74
|
+
* decisive one: this is a fact the reader is entitled to see, so the display reconstruction emits it
|
|
75
|
+
* — as a `system` message, which is what it is — rather than dropping it as loop machinery.
|
|
76
|
+
*
|
|
77
|
+
* A host MUST build these here rather than assembling the `providerOptions` markers itself: the keys
|
|
78
|
+
* and namespace are shared with the turn-boundary and display predicates, and a hand-rolled copy
|
|
79
|
+
* would fork them silently — the message would store fine and then be read as a genuine turn start,
|
|
80
|
+
* or be dropped from the transcript with no indication why.
|
|
81
|
+
*/
|
|
82
|
+
export declare function syntheticRecordMessage(text: string): ModelMessage;
|
|
83
|
+
/** Whether the loop or the host synthesized this message (see {@link syntheticUserMessage}) rather than a human sending it. */
|
|
53
84
|
export declare function isSyntheticUserMessage(message: ModelMessage): boolean;
|
|
85
|
+
/** Whether this is a host-appended record of out-of-band work (see {@link syntheticRecordMessage}) — synthetic, but displayed. */
|
|
86
|
+
export declare function isSyntheticRecordMessage(message: ModelMessage): boolean;
|
|
54
87
|
type LegacyContent = string | Array<Record<string, unknown>>;
|
|
55
88
|
export interface LegacyMessageRow {
|
|
56
89
|
readonly threadId: string;
|
|
@@ -31,8 +31,19 @@ export function parseStoredMessageEnvelope(value, identity) {
|
|
|
31
31
|
* harness fact from the loop, through `appendTurn`, into a row.
|
|
32
32
|
*/
|
|
33
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. */
|
|
34
|
+
/** The {@link HARNESS_PROVIDER_NAMESPACE} key marking a message the loop or host synthesized rather than one a human sent. */
|
|
35
35
|
export const SYNTHETIC_MESSAGE_KEY = "synthetic";
|
|
36
|
+
/**
|
|
37
|
+
* The {@link HARNESS_PROVIDER_NAMESPACE} key marking a synthetic message that is a RECORD OF FACT
|
|
38
|
+
* the reader is meant to see, as opposed to loop machinery they are not.
|
|
39
|
+
*
|
|
40
|
+
* Always accompanies {@link SYNTHETIC_MESSAGE_KEY} — a record is synthetic in the sense that matters
|
|
41
|
+
* to every turn-boundary reader, and must never open a turn. This key answers the one question
|
|
42
|
+
* `synthetic` alone cannot: whether the message should be RENDERED. The loop's truncation nudge must
|
|
43
|
+
* not be (it would show words nobody typed); an embedder's out-of-band record must be (it is the
|
|
44
|
+
* only trace in the conversation that the work happened at all).
|
|
45
|
+
*/
|
|
46
|
+
export const SYNTHETIC_RECORD_KEY = "syntheticRecord";
|
|
36
47
|
/** The {@link HARNESS_PROVIDER_NAMESPACE} key marking an assistant message whose production a client abort cut off. */
|
|
37
48
|
export const INTERRUPTED_MESSAGE_KEY = "interrupted";
|
|
38
49
|
/**
|
|
@@ -62,14 +73,18 @@ export function isInterruptedMessage(message) {
|
|
|
62
73
|
return message.providerOptions?.[HARNESS_PROVIDER_NAMESPACE]?.[INTERRUPTED_MESSAGE_KEY] === true;
|
|
63
74
|
}
|
|
64
75
|
/**
|
|
65
|
-
* Build a `user` message the
|
|
66
|
-
* truncated at its output-token limit.
|
|
76
|
+
* Build a `user` message the LOOP synthesized rather than a human typing it — today, the nudge that
|
|
77
|
+
* continues a reply the model truncated at its output-token limit.
|
|
67
78
|
*
|
|
68
79
|
* It has to carry the `user` role because the wire format requires a user turn after a truncated
|
|
69
80
|
* assistant message, but it is not user input, and the difference is load-bearing: a `user` message is
|
|
70
81
|
* what OPENS a conversation turn, so an unmarked synthetic one reads as a spurious turn boundary —
|
|
71
82
|
* splitting one turn into two for the token window and, worse, giving a tail-turn removal a cut point in
|
|
72
83
|
* the middle of a turn. Marking it is what lets {@link isSyntheticUserMessage} keep those readers honest.
|
|
84
|
+
*
|
|
85
|
+
* This one is loop MACHINERY and is not rendered: showing it would put words on screen nobody typed.
|
|
86
|
+
* An embedder recording out-of-band work — an analysis run's outcome — wants
|
|
87
|
+
* {@link syntheticRecordMessage} instead, which is equally non-turn-opening but IS displayed.
|
|
73
88
|
*/
|
|
74
89
|
export function syntheticUserMessage(text) {
|
|
75
90
|
return {
|
|
@@ -78,10 +93,36 @@ export function syntheticUserMessage(text) {
|
|
|
78
93
|
providerOptions: { [HARNESS_PROVIDER_NAMESPACE]: { [SYNTHETIC_MESSAGE_KEY]: true } },
|
|
79
94
|
};
|
|
80
95
|
}
|
|
81
|
-
/**
|
|
96
|
+
/**
|
|
97
|
+
* Build a record of out-of-band work for an embedder to append between turns — an analysis run's
|
|
98
|
+
* outcome, which the model should read on its next turn but which nobody said.
|
|
99
|
+
*
|
|
100
|
+
* Synthetic in every sense that matters to a turn-boundary reader ({@link isSyntheticUserMessage} is
|
|
101
|
+
* true of it), so it cannot split a turn for the token window nor hand a tail retraction a mid-turn
|
|
102
|
+
* cut point. It differs from {@link syntheticUserMessage} in exactly one respect, and it is the
|
|
103
|
+
* decisive one: this is a fact the reader is entitled to see, so the display reconstruction emits it
|
|
104
|
+
* — as a `system` message, which is what it is — rather than dropping it as loop machinery.
|
|
105
|
+
*
|
|
106
|
+
* A host MUST build these here rather than assembling the `providerOptions` markers itself: the keys
|
|
107
|
+
* and namespace are shared with the turn-boundary and display predicates, and a hand-rolled copy
|
|
108
|
+
* would fork them silently — the message would store fine and then be read as a genuine turn start,
|
|
109
|
+
* or be dropped from the transcript with no indication why.
|
|
110
|
+
*/
|
|
111
|
+
export function syntheticRecordMessage(text) {
|
|
112
|
+
return {
|
|
113
|
+
role: "user",
|
|
114
|
+
content: text,
|
|
115
|
+
providerOptions: { [HARNESS_PROVIDER_NAMESPACE]: { [SYNTHETIC_MESSAGE_KEY]: true, [SYNTHETIC_RECORD_KEY]: true } },
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
/** Whether the loop or the host synthesized this message (see {@link syntheticUserMessage}) rather than a human sending it. */
|
|
82
119
|
export function isSyntheticUserMessage(message) {
|
|
83
120
|
return message.providerOptions?.[HARNESS_PROVIDER_NAMESPACE]?.[SYNTHETIC_MESSAGE_KEY] === true;
|
|
84
121
|
}
|
|
122
|
+
/** Whether this is a host-appended record of out-of-band work (see {@link syntheticRecordMessage}) — synthetic, but displayed. */
|
|
123
|
+
export function isSyntheticRecordMessage(message) {
|
|
124
|
+
return message.providerOptions?.[HARNESS_PROVIDER_NAMESPACE]?.[SYNTHETIC_RECORD_KEY] === true;
|
|
125
|
+
}
|
|
85
126
|
function providerOptionsFromLegacy(block) {
|
|
86
127
|
// A block may carry both a signed thinking signature and cache_control;
|
|
87
128
|
// both are required for valid continuation, so both must survive.
|
|
@@ -20,7 +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
|
+
import { isInterruptedMessage, isSyntheticRecordMessage, isSyntheticUserMessage } from "./ai-sdk-message-storage.js";
|
|
24
24
|
function genericToolCall(block) {
|
|
25
25
|
return {
|
|
26
26
|
type: "tool-call",
|
|
@@ -84,7 +84,15 @@ export async function contentToCortexMessages(messages, resolveCard) {
|
|
|
84
84
|
// interruption marker — that rides assistant rows — so this cannot interact
|
|
85
85
|
// with the marker-fold logic below. The assistant rows it separated then
|
|
86
86
|
// become adjacent and coalesce into one bubble, which is the shape we want.
|
|
87
|
-
|
|
87
|
+
//
|
|
88
|
+
// A host-appended RECORD is the exception, and the distinction is exactly why the
|
|
89
|
+
// two markers exist separately: it is equally synthetic for every turn-boundary
|
|
90
|
+
// reader, but it is a fact the reader is entitled to see — the only trace in the
|
|
91
|
+
// conversation that out-of-band work happened at all. Dropping it here would make
|
|
92
|
+
// an embedder's durable record write successfully and then be invisible, with
|
|
93
|
+
// nothing to indicate why.
|
|
94
|
+
const isRecord = isSyntheticRecordMessage(message.message);
|
|
95
|
+
if (isSyntheticUserMessage(message.message) && !isRecord)
|
|
88
96
|
continue;
|
|
89
97
|
const parts = await rowToParts(message.message, resolveCard);
|
|
90
98
|
// The marker is read before the zero-parts drop so the flag can never be lost to it:
|
|
@@ -100,7 +108,15 @@ export async function contentToCortexMessages(messages, resolveCard) {
|
|
|
100
108
|
}
|
|
101
109
|
continue;
|
|
102
110
|
}
|
|
103
|
-
|
|
111
|
+
// A record carries the `user` role for the wire format and for the turn-boundary
|
|
112
|
+
// predicates, but nobody said it. Emitting it AS `user` would attribute
|
|
113
|
+
// system-authored text to the reader — a lie about who spoke, and one that would
|
|
114
|
+
// also mislead them about what they can retract. `system` is what it actually is.
|
|
115
|
+
const role = isRecord
|
|
116
|
+
? "system"
|
|
117
|
+
: message.message.role === "tool"
|
|
118
|
+
? "assistant"
|
|
119
|
+
: message.message.role;
|
|
104
120
|
const prev = out[out.length - 1];
|
|
105
121
|
if (prev && prev.role === role && role === "assistant") {
|
|
106
122
|
prev.parts.push(...parts);
|
|
@@ -15,7 +15,7 @@ export async function backfillAiSdkMessageEnvelopes(client) {
|
|
|
15
15
|
role: row.role,
|
|
16
16
|
content: row.content_jsonb,
|
|
17
17
|
});
|
|
18
|
-
await client.query("UPDATE messages SET message_envelope = $1::
|
|
18
|
+
await client.query("UPDATE messages SET message_envelope = $1::json WHERE thread_id = $2 AND seq = $3::bigint", [
|
|
19
19
|
JSON.stringify(envelopeMessage(message)),
|
|
20
20
|
row.thread_id,
|
|
21
21
|
row.seq,
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
*/
|
|
23
23
|
import { metrics } from "@opentelemetry/api";
|
|
24
24
|
import { ResultAsync, ok, okAsync } from "neverthrow";
|
|
25
|
+
import { stripNulCharacters } from "../input-sanitization.js";
|
|
25
26
|
import { tryMutation, tryQuery, withTransaction } from "../lib/db-result.js";
|
|
26
27
|
import { countTokens } from "./count-tokens.js";
|
|
27
28
|
import { HARNESS_PROVIDER_NAMESPACE, SYNTHETIC_MESSAGE_KEY, envelopeMessage, isSyntheticUserMessage, parseStoredMessageEnvelope, } from "./ai-sdk-message-storage.js";
|
|
@@ -36,11 +37,18 @@ function okVoid() {
|
|
|
36
37
|
*
|
|
37
38
|
* - a tool result, which in AI SDK terms is a `tool`-role message, so a mid-turn
|
|
38
39
|
* tool continuation never matches in the first place;
|
|
39
|
-
* - a message the LOOP synthesized
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
40
|
+
* - a message the LOOP or the HOST synthesized (`syntheticUserMessage`) — the
|
|
41
|
+
* truncated-reply nudge the loop inserts mid-turn, and the record an embedder
|
|
42
|
+
* appends between turns for work that happened outside the conversation, such as
|
|
43
|
+
* an analysis run's outcome. Both carry the `user` role for the wire format and
|
|
44
|
+
* neither is user input. Reading one as a boundary would split one turn into
|
|
45
|
+
* two: the token window would evict half a turn, and `retractLastTurn` would cut
|
|
46
|
+
* its tail in the middle of a turn rather than at its head.
|
|
47
|
+
*
|
|
48
|
+
* A host-appended record therefore belongs to the turn preceding it, so a tail
|
|
49
|
+
* retraction that removes that turn removes the record with it. That is the
|
|
50
|
+
* accepted consequence of the exclusion, not a defect — the alternative, letting
|
|
51
|
+
* it open a turn, is the exact failure the marker exists to prevent.
|
|
44
52
|
*
|
|
45
53
|
* {@link GENUINE_USER_START_SQL} is the twin of this predicate over stored
|
|
46
54
|
* envelopes; the two are built from the same constants so they cannot drift.
|
|
@@ -61,6 +69,22 @@ function isGenuineUserStart(message) {
|
|
|
61
69
|
*/
|
|
62
70
|
const GENUINE_USER_START_SQL = `message_envelope->'message'->>'role' = 'user'
|
|
63
71
|
AND message_envelope->'message'->'providerOptions'->'${HARNESS_PROVIDER_NAMESPACE}'->>'${SYNTHETIC_MESSAGE_KEY}' IS DISTINCT FROM 'true'`;
|
|
72
|
+
/**
|
|
73
|
+
* Serialize a message's storage envelope, dropping NUL from every string it
|
|
74
|
+
* carries ({@link stripNulCharacters}).
|
|
75
|
+
*
|
|
76
|
+
* A stored NUL does not break the insert on a `json` column, nor the whole-row
|
|
77
|
+
* reads — it breaks {@link GENUINE_USER_START_SQL}, whose JSON operators refuse
|
|
78
|
+
* to walk a document containing one, so a single poisoned row would make the
|
|
79
|
+
* thread's tail unretractable. The loop already strips NUL where it builds a
|
|
80
|
+
* tool result, so this scrubs nothing on the harness's own path; it holds the
|
|
81
|
+
* invariant for every other writer.
|
|
82
|
+
*
|
|
83
|
+
* The replacer sees values, not keys — the same accepted limit as the loop's.
|
|
84
|
+
*/
|
|
85
|
+
function serializeEnvelope(message) {
|
|
86
|
+
return JSON.stringify(envelopeMessage(message), (_key, value) => (typeof value === "string" ? stripNulCharacters(value) : value));
|
|
87
|
+
}
|
|
64
88
|
/**
|
|
65
89
|
* Group rows (oldest-first) into turns at genuine-user-start boundaries.
|
|
66
90
|
* Generic over the row shape so the token-windowed read (`loadRecent`) and
|
|
@@ -148,11 +172,13 @@ export function createThreadHistory(pool) {
|
|
|
148
172
|
// prior so the first `err` short-circuits — and `withTransaction`
|
|
149
173
|
// re-throws it to force ROLLBACK (a returned `err` that does not
|
|
150
174
|
// reach `withTransaction` would COMMIT silently).
|
|
151
|
-
messages.reduce((chain, message, i) => chain.andThen(() => tryMutation("thread-history.appendTurn.insert", () => client.query(
|
|
152
|
-
|
|
175
|
+
messages.reduce((chain, message, i) => chain.andThen(() => tryMutation("thread-history.appendTurn.insert", () => client.query(
|
|
176
|
+
// `::json`, never `::jsonb` — see the column comment in the state-init DDL.
|
|
177
|
+
`INSERT INTO messages (thread_id, seq, message_envelope, tokens)
|
|
178
|
+
VALUES ($1, $2, $3::json, $4)
|
|
153
179
|
ON CONFLICT (thread_id, seq) DO UPDATE
|
|
154
180
|
SET message_envelope = EXCLUDED.message_envelope,
|
|
155
|
-
tokens = EXCLUDED.tokens`, [threadId, startSeq + i,
|
|
181
|
+
tokens = EXCLUDED.tokens`, [threadId, startSeq + i, serializeEnvelope(message), countTokens(message.content)])).map(() => undefined)), okVoid()))
|
|
156
182
|
.andThen(() =>
|
|
157
183
|
// Thread listings sort on `cortex_analysis_threads.updated_at`, and
|
|
158
184
|
// the only other writer of it is the title update — so without this
|