@inflexa-ai/harness 0.39.2 → 0.41.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -3
- package/dist/agents/conversation-agent.d.ts +3 -0
- package/dist/agents/conversation-agent.js +13 -2
- package/dist/agents/report-session-agent.d.ts +3 -0
- package/dist/agents/report-session-agent.js +2 -0
- package/dist/agents/sandbox/cheminformatics-agent.js +0 -3
- package/dist/agents/sandbox/shared.d.ts +11 -2
- package/dist/agents/sandbox/shared.js +4 -0
- package/dist/app/chat-turn.d.ts +104 -12
- package/dist/app/chat-turn.js +180 -21
- package/dist/app/message-assembly.d.ts +16 -52
- package/dist/app/message-assembly.js +41 -68
- package/dist/app/run-activity.d.ts +4 -6
- package/dist/app/run-activity.js +7 -27
- package/dist/app/spawn-report-session.d.ts +6 -11
- package/dist/app/spawn-report-session.js +5 -6
- package/dist/app/synthesize-run.d.ts +3 -0
- package/dist/app/synthesize-run.js +1 -0
- package/dist/billing/usage-recorder.d.ts +3 -4
- package/dist/billing/usage-recorder.js +3 -4
- package/dist/contracts/chat-parts.d.ts +11 -1
- package/dist/contracts/chat-parts.js +1 -0
- package/dist/contracts/index.d.ts +1 -1
- package/dist/contracts/part-registry.js +1 -0
- package/dist/contracts/report-blocks.d.ts +8 -8
- package/dist/contracts/schemas/chat-parts.d.ts +32 -9
- package/dist/contracts/schemas/chat-parts.js +10 -0
- package/dist/execution/artifact-metadata.d.ts +26 -33
- package/dist/execution/artifact-metadata.js +44 -106
- package/dist/execution/post-step-pipeline.d.ts +23 -16
- package/dist/execution/post-step-pipeline.js +19 -21
- package/dist/execution/run-synthesis.d.ts +3 -0
- package/dist/execution/run-synthesis.js +11 -1
- package/dist/execution/step-summary.d.ts +21 -30
- package/dist/execution/step-summary.js +30 -57
- package/dist/index.d.ts +9 -8
- package/dist/index.js +15 -34
- package/dist/lib/zod-issues.js +2 -4
- package/dist/loop/compaction.d.ts +20 -0
- package/dist/loop/compaction.js +2 -0
- package/dist/loop/continue-agent.d.ts +40 -0
- package/dist/loop/continue-agent.js +38 -0
- package/dist/loop/metrics.d.ts +30 -22
- package/dist/loop/metrics.js +51 -22
- package/dist/loop/run-agent.d.ts +111 -13
- package/dist/loop/run-agent.js +467 -220
- package/dist/loop/run-to-terminal.d.ts +16 -17
- package/dist/loop/run-to-terminal.js +24 -35
- package/dist/loop/tool-mask.d.ts +23 -0
- package/dist/loop/tool-mask.js +46 -0
- package/dist/loop/tool-output.d.ts +48 -0
- package/dist/loop/tool-output.js +79 -0
- package/dist/loop/types.d.ts +6 -3
- package/dist/memory/ai-sdk-message-storage.d.ts +49 -2
- package/dist/memory/ai-sdk-message-storage.js +78 -2
- package/dist/memory/conversation-display-recorder.d.ts +6 -0
- package/dist/memory/conversation-display-recorder.js +64 -22
- package/dist/memory/conversation-display-replay.d.ts +5 -14
- package/dist/memory/conversation-display-replay.js +66 -32
- package/dist/memory/conversation-display-storage.d.ts +2 -1
- package/dist/memory/conversation-display-storage.js +2 -1
- package/dist/memory/conversation-view.d.ts +53 -0
- package/dist/memory/conversation-view.js +130 -0
- package/dist/memory/count-tokens.d.ts +5 -7
- package/dist/memory/count-tokens.js +7 -9
- package/dist/memory/thread-history.d.ts +74 -102
- package/dist/memory/thread-history.js +181 -246
- package/dist/memory/thread-store.d.ts +3 -3
- package/dist/memory/thread-store.js +7 -2
- package/dist/memory/tool-call-integrity.d.ts +15 -21
- package/dist/memory/tool-call-integrity.js +31 -38
- package/dist/memory/working-memory.d.ts +2 -5
- package/dist/memory/working-memory.js +2 -5
- package/dist/prompts/SOUL.d.ts +2 -2
- package/dist/prompts/SOUL.js +2 -7
- package/dist/prompts/analogical-reasoner.d.ts +1 -1
- package/dist/prompts/analogical-reasoner.js +16 -32
- package/dist/prompts/briefing.js +1 -1
- package/dist/prompts/compaction.d.ts +4 -0
- package/dist/prompts/compaction.js +24 -0
- package/dist/prompts/conversation.d.ts +1 -1
- package/dist/prompts/conversation.js +26 -35
- package/dist/prompts/execute-analysis/step-summary.js +7 -5
- package/dist/prompts/literature-reviewer.d.ts +1 -1
- package/dist/prompts/literature-reviewer.js +29 -34
- package/dist/prompts/planner.js +15 -29
- package/dist/prompts/report-session.d.ts +1 -1
- package/dist/prompts/report-session.js +2 -33
- package/dist/prompts/sandbox/bulk-transcriptomics-agent.d.ts +1 -1
- package/dist/prompts/sandbox/bulk-transcriptomics-agent.js +1 -2
- package/dist/prompts/sandbox/cheminformatics-agent.d.ts +1 -1
- package/dist/prompts/sandbox/cheminformatics-agent.js +10 -29
- package/dist/prompts/sandbox/chromatin-agent.d.ts +1 -1
- package/dist/prompts/sandbox/chromatin-agent.js +1 -2
- package/dist/prompts/sandbox/data-profiler.js +6 -3
- package/dist/prompts/sandbox/dna-methylation-agent.d.ts +1 -1
- package/dist/prompts/sandbox/dna-methylation-agent.js +2 -7
- package/dist/prompts/sandbox/drug-repurposing-agent.d.ts +1 -1
- package/dist/prompts/sandbox/drug-repurposing-agent.js +20 -24
- package/dist/prompts/sandbox/enrichment-agent.d.ts +1 -1
- package/dist/prompts/sandbox/enrichment-agent.js +3 -2
- package/dist/prompts/sandbox/genomic-variant-agent.d.ts +1 -1
- package/dist/prompts/sandbox/genomic-variant-agent.js +0 -2
- package/dist/prompts/sandbox/immune-profiling-agent.d.ts +1 -1
- package/dist/prompts/sandbox/immune-profiling-agent.js +7 -14
- package/dist/prompts/sandbox/metabolomics-agent.d.ts +1 -1
- package/dist/prompts/sandbox/metabolomics-agent.js +0 -2
- package/dist/prompts/sandbox/microbiome-agent.d.ts +1 -1
- package/dist/prompts/sandbox/microbiome-agent.js +14 -21
- package/dist/prompts/sandbox/multi-omics-integration-agent.d.ts +1 -1
- package/dist/prompts/sandbox/multi-omics-integration-agent.js +9 -19
- package/dist/prompts/sandbox/multimodal-sc-agent.d.ts +1 -1
- package/dist/prompts/sandbox/multimodal-sc-agent.js +4 -5
- package/dist/prompts/sandbox/network-agent.d.ts +1 -1
- package/dist/prompts/sandbox/network-agent.js +7 -8
- package/dist/prompts/sandbox/pkpd-clinical-response-agent.d.ts +1 -1
- package/dist/prompts/sandbox/pkpd-clinical-response-agent.js +19 -19
- package/dist/prompts/sandbox/proteomics-agent.d.ts +1 -1
- package/dist/prompts/sandbox/proteomics-agent.js +3 -4
- package/dist/prompts/sandbox/scientific-executor.d.ts +1 -1
- package/dist/prompts/sandbox/scientific-executor.js +7 -10
- package/dist/prompts/sandbox/single-cell-agent.d.ts +1 -1
- package/dist/prompts/sandbox/single-cell-agent.js +2 -4
- package/dist/prompts/sandbox/spatial-omics-agent.d.ts +1 -1
- package/dist/prompts/sandbox/spatial-omics-agent.js +3 -5
- package/dist/prompts/sandbox/statistical-modeling-agent.d.ts +1 -1
- package/dist/prompts/sandbox/statistical-modeling-agent.js +1 -1
- package/dist/prompts/sandbox/translational-safety-agent.d.ts +1 -1
- package/dist/prompts/sandbox/translational-safety-agent.js +10 -19
- package/dist/prompts/sandbox-standards.d.ts +7 -7
- package/dist/prompts/sandbox-standards.js +74 -76
- package/dist/prompts/synthesis-agent.d.ts +1 -1
- package/dist/prompts/synthesis-agent.js +16 -42
- package/dist/providers/ai-sdk.d.ts +39 -6
- package/dist/providers/ai-sdk.js +117 -18
- package/dist/providers/errors.d.ts +2 -0
- package/dist/providers/errors.js +5 -0
- package/dist/providers/prompt-cache.d.ts +43 -41
- package/dist/providers/prompt-cache.js +47 -41
- package/dist/providers/reasoning.d.ts +10 -21
- package/dist/providers/reasoning.js +10 -21
- package/dist/providers/types.d.ts +21 -5
- package/dist/report-model/authoring-grammar.d.ts +2 -2
- package/dist/runtime/assemble.d.ts +9 -7
- package/dist/runtime/assemble.js +7 -3
- package/dist/sandbox/create-sandbox.d.ts +2 -2
- package/dist/sandbox/submit-exec.d.ts +1 -1
- package/dist/schemas/artifact-manifest.d.ts +4 -4
- package/dist/state/init.js +39 -4
- package/dist/state/purge-analysis.js +4 -0
- package/dist/state/tool-outputs.d.ts +4 -0
- package/dist/state/tool-outputs.js +30 -0
- package/dist/tasks/data-profile.d.ts +3 -0
- package/dist/tasks/data-profile.js +2 -0
- package/dist/tools/ad-hoc-router.d.ts +8 -0
- package/dist/tools/ad-hoc-router.js +21 -2
- package/dist/tools/display/show-user.d.ts +1 -1
- package/dist/tools/execute-analysis.d.ts +3 -0
- package/dist/tools/execute-analysis.js +9 -1
- package/dist/tools/read-tool-output.d.ts +7 -0
- package/dist/tools/read-tool-output.js +130 -0
- package/dist/tools/research/generate-analogy-report.d.ts +10 -26
- package/dist/tools/research/generate-analogy-report.js +76 -163
- package/dist/tools/research/generate-plan.d.ts +3 -0
- package/dist/tools/research/generate-plan.js +4 -1
- package/dist/tools/research/literature-reviewer.d.ts +3 -0
- package/dist/tools/research/literature-reviewer.js +3 -0
- package/dist/tools/sandbox/report-blocker.d.ts +18 -5
- package/dist/tools/sandbox/report-blocker.js +54 -6
- package/dist/tools/sandbox/submit-file-metadata.d.ts +30 -0
- package/dist/tools/sandbox/submit-file-metadata.js +67 -0
- package/dist/tools/start-report-session.d.ts +1 -3
- package/dist/tools/start-report-session.js +0 -2
- package/dist/tools/workspace/execute-command.js +7 -4
- package/dist/tools/workspace/result-bounds.d.ts +4 -4
- package/dist/tools/workspace/result-bounds.js +6 -5
- package/dist/tools/workspace/workspace-search.d.ts +1 -1
- package/dist/workflows/execute-analysis.d.ts +3 -0
- package/dist/workflows/execute-analysis.js +1 -0
- package/dist/workflows/sandbox-step.d.ts +30 -9
- package/dist/workflows/sandbox-step.js +37 -8
- package/package.json +9 -9
- package/dist/providers/anthropic.d.ts +0 -25
- package/dist/providers/anthropic.js +0 -28
package/dist/loop/run-agent.js
CHANGED
|
@@ -5,21 +5,25 @@ import { stripNulCharacters } from "../input-sanitization.js";
|
|
|
5
5
|
import { createNoopLogger } from "../lib/console-logger.js";
|
|
6
6
|
import { deliverNotice } from "../lib/hooks.js";
|
|
7
7
|
import { ATTR_INFLEXA_TOOL_USE_ID, passThroughSpan, stableSpan } from "../lib/otel-spans.js";
|
|
8
|
-
import { ResultError } from "../lib/result.js";
|
|
8
|
+
import { ResultError, unwrapOrThrow } from "../lib/result.js";
|
|
9
9
|
import { describeZodIssueShapes } from "../lib/zod-issue-shape.js";
|
|
10
10
|
import { hintForZodIssue, repairToolInput } from "../lib/zod-issues.js";
|
|
11
|
-
import { markInterruptedMessage, syntheticUserMessage } from "../memory/ai-sdk-message-storage.js";
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
11
|
+
import { dropMarkerMessage, markCompactionExchange, markInterruptedMessage, summaryMarkerMessage, syntheticUserMessage, } from "../memory/ai-sdk-message-storage.js";
|
|
12
|
+
import { conversationView, keptTurnsForDrop, viewTokens } from "../memory/conversation-view.js";
|
|
13
|
+
import { answerUnansweredToolCalls } from "../memory/tool-call-integrity.js";
|
|
14
|
+
import { classifyProviderError, extractStatus } from "../providers/errors.js";
|
|
15
|
+
import { DEFAULT_PROMPT_CACHE, withPromptCacheBreakpoint, withSystemPromptBreakpoint } from "../providers/prompt-cache.js";
|
|
16
|
+
import { COMPACTION_MAX_REQUESTS } from "./compaction.js";
|
|
17
|
+
import { continueAgent } from "./continue-agent.js";
|
|
16
18
|
import { resultStep } from "./run-step.js";
|
|
17
19
|
import { AskRejectedError, UnavailableAsk } from "../tools/approval/contract.js";
|
|
18
20
|
import { isToolError, readToolResultImages } from "../tools/define-tool.js";
|
|
19
21
|
import { labelToolFailure, labelToolValidationFailure, recordToolException, traceAgentRun, traceToolCall } from "./genai-spans.js";
|
|
20
|
-
import { addChatUsage, hasReportedUsage, recordAgentRun } from "./metrics.js";
|
|
22
|
+
import { addChatUsage, countChatTokens, hasReportedUsage, recordAgentRun } from "./metrics.js";
|
|
21
23
|
import { computeDetail, computeResultDetail } from "./tool-detail.js";
|
|
24
|
+
import { refusalsFor } from "./tool-mask.js";
|
|
22
25
|
import { toolOutcomeForOutputType } from "./tool-outcome.js";
|
|
26
|
+
import { excerptOf, keptTextOf, READ_TOOL_OUTPUT_TOOL_ID, resultTextOf, TOOL_RESULT_CAP, toolOutputRef, withExcerpt, } from "./tool-output.js";
|
|
23
27
|
const TRUNCATED_PROSE_STEER = "Your previous reply was cut off at the output-token limit; continue concisely, or finish via your terminal tool.";
|
|
24
28
|
const TRUNCATED_TOOL_USE_ERROR = "Your previous tool call was cut off at the output-token limit and was not executed. Retry with a smaller payload, writing in incremental pieces.";
|
|
25
29
|
export const DEFAULT_STEP_NAME_FORMATTER = {
|
|
@@ -27,17 +31,43 @@ export const DEFAULT_STEP_NAME_FORMATTER = {
|
|
|
27
31
|
tool: (name, id) => `tool-${name}-${id}`,
|
|
28
32
|
};
|
|
29
33
|
/** A run that receives no turn accumulator is the turn's root. See `RunAgentOptions.turnUsage`. */
|
|
30
|
-
const isTurnRoot = (opts) => opts.turnUsage === undefined;
|
|
34
|
+
export const isTurnRoot = (opts) => opts.turnUsage === undefined;
|
|
31
35
|
export function runAgent(agent, initial, session, opts) {
|
|
32
36
|
return traceAgentRun(agent.id, session, isTurnRoot(opts), () => runAgentLoop(agent, initial, session, opts));
|
|
33
37
|
}
|
|
38
|
+
/** The harness request of the wrap-up at the iteration cap. */
|
|
39
|
+
export const WRAP_UP_REQUEST = "You reached the limit of tool rounds for this run, thus no tool can run now. Answer in text with the results that you have.";
|
|
40
|
+
/** The cap of wrap-up requests: one answer, and one more request after a refused call. */
|
|
41
|
+
export const WRAP_UP_MAX_REQUESTS = 2;
|
|
34
42
|
async function runAgentLoop(agent, initial, session, opts) {
|
|
43
|
+
const formatStepName = opts.formatStepName ?? DEFAULT_STEP_NAME_FORMATTER;
|
|
44
|
+
const loop = openLoop(agent, initial, session, opts, agent.id);
|
|
45
|
+
const task = await loop.runSegment({ mask: opts.toolMask, maxRequests: agent.maxIterations, stepNames: formatStepName, firstIndex: 0, checksBudget: true });
|
|
46
|
+
if (task !== "capped")
|
|
47
|
+
return task;
|
|
48
|
+
// The wrap-up refuses calls through the mask, not `toolChoice` (see its
|
|
49
|
+
// CAUTION), and keeps step name `llm(maxIterations + k)` so replay finds the
|
|
50
|
+
// reply an earlier, single-call wrap-up stored under the same name.
|
|
51
|
+
const wrapUp = await loop.runSegment({
|
|
52
|
+
mask: "none",
|
|
53
|
+
maxRequests: WRAP_UP_MAX_REQUESTS,
|
|
54
|
+
stepNames: { llm: (k) => formatStepName.llm(agent.maxIterations + k), tool: formatStepName.tool },
|
|
55
|
+
firstIndex: agent.maxIterations,
|
|
56
|
+
requestText: WRAP_UP_REQUEST,
|
|
57
|
+
closesCappedRun: true,
|
|
58
|
+
});
|
|
59
|
+
return wrapUp === "capped" ? loop.endCapped() : wrapUp;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Open the loop over one conversation. `metricAgentId` is the id that usage
|
|
63
|
+
* and run metrics count under, which is not always `agent.id`.
|
|
64
|
+
*/
|
|
65
|
+
export function openLoop(agent, initial, session, opts, metricAgentId) {
|
|
35
66
|
const { provider, signal, emit, runStep } = opts;
|
|
36
67
|
// The step of a model call or a tool call writes no span. The `chat` span and
|
|
37
68
|
// the `execute_tool` span under it describe the call already, and they attach
|
|
38
69
|
// to the `invoke_agent` span of this run.
|
|
39
70
|
const callStep = (name, fn) => passThroughSpan(name, () => runStep(name, fn));
|
|
40
|
-
const formatStepName = opts.formatStepName ?? DEFAULT_STEP_NAME_FORMATTER;
|
|
41
71
|
const configuredFatalLoopError = opts.isFatalLoopError ?? (() => false);
|
|
42
72
|
// AbortError is control flow, not a tool failure. Always compose it with the
|
|
43
73
|
// host's fatal predicate so request cancellation cannot be converted into a
|
|
@@ -47,10 +77,29 @@ async function runAgentLoop(agent, initial, session, opts) {
|
|
|
47
77
|
throw new Error(`Provider/model cannot run tool-required agent "${agent.id}"`);
|
|
48
78
|
}
|
|
49
79
|
const messages = [...initial];
|
|
80
|
+
// The loop never changes a message before `givenCount`, because the sink can hold it already.
|
|
81
|
+
let givenCount = initial.length;
|
|
82
|
+
// Stays true when the sink rejects, thus the throw path does not call the sink again.
|
|
83
|
+
let sinkPending = false;
|
|
84
|
+
const giveRound = async () => {
|
|
85
|
+
if (opts.onRound === undefined || messages.length === givenCount)
|
|
86
|
+
return;
|
|
87
|
+
sinkPending = true;
|
|
88
|
+
await opts.onRound({ messages: messages.slice(givenCount) });
|
|
89
|
+
sinkPending = false;
|
|
90
|
+
givenCount = messages.length;
|
|
91
|
+
};
|
|
50
92
|
const source = {
|
|
51
93
|
agentId: session.provenance.agentId ?? agent.id,
|
|
52
94
|
callPath: session.provenance.callPath,
|
|
53
95
|
};
|
|
96
|
+
const policy = opts.compaction;
|
|
97
|
+
const viewOptions = { keepFirstTurn: policy?.keepFirstTurn === true };
|
|
98
|
+
const viewOf = () => (policy === undefined ? messages : conversationView(messages, viewOptions).messages);
|
|
99
|
+
// The steer of a truncated prose reply and the reply after it complete one reply, thus no exchange goes between them.
|
|
100
|
+
let continuesTruncation = false;
|
|
101
|
+
let compactionStopped = false;
|
|
102
|
+
let compactions = 0;
|
|
54
103
|
// Bound from the same `source` the emitted events carry: one derivation feeding
|
|
55
104
|
// both sinks, so a record and an event cannot disagree about who produced them.
|
|
56
105
|
// `callPath` rides as an array rather than a joined string — the queryable form;
|
|
@@ -61,6 +110,13 @@ async function runAgentLoop(agent, initial, session, opts) {
|
|
|
61
110
|
// The collector belongs to this run, thus two loops never share one.
|
|
62
111
|
const encoding = { placement: imagePlacementFor(provider.capabilities), deferredImages: [], log };
|
|
63
112
|
const toolsById = new Map(agent.tools.map((t) => [t.id, t]));
|
|
113
|
+
// Only the read tool of the agent can read a kept text, thus an agent without it keeps nothing.
|
|
114
|
+
const cut = {
|
|
115
|
+
store: toolsById.has(READ_TOOL_OUTPUT_TOOL_ID) ? opts.toolOutputStore : undefined,
|
|
116
|
+
session,
|
|
117
|
+
invocationId: opts.invocationId,
|
|
118
|
+
log,
|
|
119
|
+
};
|
|
64
120
|
const toolDefs = Object.fromEntries(agent.tools.map((t) => [
|
|
65
121
|
t.id,
|
|
66
122
|
aiTool({
|
|
@@ -79,13 +135,13 @@ async function runAgentLoop(agent, initial, session, opts) {
|
|
|
79
135
|
// turn total. Descendants receive the root's object and mutate it in place.
|
|
80
136
|
const turnUsage = opts.turnUsage ?? {};
|
|
81
137
|
const turnRoot = isTurnRoot(opts);
|
|
82
|
-
const toolCtx = (tu) => ({
|
|
138
|
+
const toolCtx = (tu, names) => ({
|
|
83
139
|
invocationId: tu.toolCallId,
|
|
84
140
|
session,
|
|
85
141
|
signal,
|
|
86
142
|
emit,
|
|
87
143
|
runStep: (name, fn) => {
|
|
88
|
-
const stepName = `${
|
|
144
|
+
const stepName = `${names.tool(tu.toolName, tu.toolCallId)}:${name}`;
|
|
89
145
|
return runStep(stepName, () => {
|
|
90
146
|
stableSpan(stepName, `tool:${tu.toolName}:${name}`, { [ATTR_INFLEXA_TOOL_USE_ID]: tu.toolCallId });
|
|
91
147
|
return fn();
|
|
@@ -104,12 +160,12 @@ async function runAgentLoop(agent, initial, session, opts) {
|
|
|
104
160
|
let toolErrorCount = 0;
|
|
105
161
|
// Resolved once, not per iteration: an identical policy across every call is
|
|
106
162
|
// itself part of the cache contract — the request prefix has to be
|
|
107
|
-
// byte-identical to be read back. The breakpoint
|
|
108
|
-
// call
|
|
163
|
+
// byte-identical to be read back. The message breakpoint is re-derived per
|
|
164
|
+
// call because it rides the transcript. The system prompt depends only on
|
|
165
|
+
// the agent type, so it is computed once.
|
|
109
166
|
const promptCache = opts.promptCache ?? DEFAULT_PROMPT_CACHE;
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
const reasoning = opts.reasoning ?? DEFAULT_REASONING;
|
|
167
|
+
const system = withSystemPromptBreakpoint(agent.systemPrompt, promptCache);
|
|
168
|
+
const reasoningField = opts.reasoning === undefined ? {} : { reasoning: opts.reasoning };
|
|
113
169
|
const usage = {};
|
|
114
170
|
// Exactly one record per completed run — never one per iteration. That bound is
|
|
115
171
|
// what keeps the default level affordable for an agent that runs long; the
|
|
@@ -122,71 +178,74 @@ async function runAgentLoop(agent, initial, session, opts) {
|
|
|
122
178
|
* carry a complete tool call beside ANY finish reason, because the call
|
|
123
179
|
* streams before the stop reason arrives, and only the dispatching branches
|
|
124
180
|
* (`tool-calls`, a call-carrying `stop`, and `length`) run one. A call this
|
|
125
|
-
* run never dispatched
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
* every tool call carries a result — and the provider boundary then refuses
|
|
130
|
-
* the whole transcript on every later turn of the thread. Scoped past
|
|
181
|
+
* run never dispatched gets a not-run result instead of being executed or
|
|
182
|
+
* dropped: every tool call needs a result, or the provider refuses the
|
|
183
|
+
* transcript on the next turn. The fix only appends; it never edits or
|
|
184
|
+
* removes a message. Scoped past
|
|
131
185
|
* `initial`: the caller's prefix is not this run's to repair.
|
|
132
186
|
*/
|
|
133
187
|
const settleTranscript = () => {
|
|
134
|
-
const
|
|
135
|
-
if (
|
|
188
|
+
const answeredCalls = answerUnansweredToolCalls(messages, initial.length);
|
|
189
|
+
if (answeredCalls.length === 0)
|
|
136
190
|
return;
|
|
137
|
-
log.warn("
|
|
138
|
-
toolCallIds:
|
|
139
|
-
tools:
|
|
191
|
+
log.warn("unanswered tool calls answered at run exit", {
|
|
192
|
+
toolCallIds: answeredCalls.map((d) => d.toolCallId),
|
|
193
|
+
tools: answeredCalls.map((d) => d.toolName),
|
|
140
194
|
});
|
|
141
195
|
};
|
|
142
196
|
const usageRecorder = opts.usageRecorder ?? createNoopUsageRecorder();
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
* reported nothing to account for, so it is folded (to no effect) and left
|
|
152
|
-
* out of the ledger rather than entered as an all-absent record.
|
|
153
|
-
*
|
|
154
|
-
* `record` is a notice. The loop does not wait for its result, thus a
|
|
155
|
-
* recorder that blocks does not make the run slower.
|
|
156
|
-
*/
|
|
157
|
-
const accountForCall = (reply, stepName) => {
|
|
158
|
-
addChatUsage(usage, reply.usage);
|
|
159
|
-
addChatUsage(turnUsage, reply.usage);
|
|
160
|
-
const reported = reply.usage;
|
|
161
|
-
if (reported === undefined || !hasReportedUsage(reported))
|
|
162
|
-
return;
|
|
163
|
-
void deliverNotice(log, "UsageRecorder.record", usageRecorder.record({
|
|
164
|
-
recordKey: recordKeyFor(session, opts.invocationId, stepName),
|
|
165
|
-
agentId: source.agentId,
|
|
166
|
-
callPath: source.callPath,
|
|
167
|
-
scope: session.scope,
|
|
168
|
-
...(session.runFrame?.runId === undefined ? {} : { runId: session.runFrame.runId }),
|
|
169
|
-
...(session.runFrame?.stepId === undefined ? {} : { stepId: session.runFrame.stepId }),
|
|
170
|
-
...(reply.requestedModelId === undefined ? {} : { requestedModelId: reply.requestedModelId }),
|
|
171
|
-
...(reply.servedModelId === undefined ? {} : { servedModelId: reply.servedModelId }),
|
|
172
|
-
usage: reported,
|
|
173
|
-
}));
|
|
197
|
+
const accounting = {
|
|
198
|
+
session,
|
|
199
|
+
agentId: source.agentId,
|
|
200
|
+
callPath: source.callPath,
|
|
201
|
+
...(opts.invocationId === undefined ? {} : { invocationId: opts.invocationId }),
|
|
202
|
+
usageRecorder,
|
|
203
|
+
logger: log,
|
|
204
|
+
rollups: [usage, turnUsage],
|
|
174
205
|
};
|
|
175
206
|
/** The rollups this loop stamps on its finish — each absent when nothing reported. */
|
|
176
207
|
const finishUsage = () => ({
|
|
177
208
|
...(hasReportedUsage(usage) ? { usage: { ...usage } } : {}),
|
|
178
209
|
...(turnRoot && hasReportedUsage(turnUsage) ? { turnUsage: { ...turnUsage } } : {}),
|
|
179
210
|
});
|
|
211
|
+
/**
|
|
212
|
+
* One model request over the view of the transcript. Token counters grow inside
|
|
213
|
+
* the step body, so a replayed step (a stored reply) does not double-count.
|
|
214
|
+
*/
|
|
215
|
+
const callModel = async (stepName, endsOnRequestRefusal) => {
|
|
216
|
+
const request = {
|
|
217
|
+
system,
|
|
218
|
+
messages: withPromptCacheBreakpoint(viewOf(), promptCache),
|
|
219
|
+
tools: toolDefs,
|
|
220
|
+
...(opts.toolChoice !== undefined ? { toolChoice: opts.toolChoice } : {}),
|
|
221
|
+
...reasoningField,
|
|
222
|
+
};
|
|
223
|
+
const chat = () => provider.chat(request, session, signal).map(countChatTokens(metricAgentId));
|
|
224
|
+
if (!endsOnRequestRefusal) {
|
|
225
|
+
const reply = await resultStep(callStep)(stepName, chat);
|
|
226
|
+
accountForChatCall(reply, { ...accounting, stepName });
|
|
227
|
+
return reply;
|
|
228
|
+
}
|
|
229
|
+
// The step gives the `Result` as its value, thus a refusal ends the segment and no step throws for it.
|
|
230
|
+
const result = await callStep(stepName, async () => await chat());
|
|
231
|
+
const refusal = result.isErr() ? requestRefusalOf(result.error) : undefined;
|
|
232
|
+
if (refusal !== undefined)
|
|
233
|
+
return { refusal };
|
|
234
|
+
const reply = unwrapOrThrow(result);
|
|
235
|
+
accountForChatCall(reply, { ...accounting, stepName });
|
|
236
|
+
return reply;
|
|
237
|
+
};
|
|
180
238
|
// The user said no. A subsequent model call would only let the agent argue
|
|
181
239
|
// with the decision, or spend a call acknowledging it; the denial tool result
|
|
182
240
|
// is itself what the surface renders, so the turn ends the moment a denial
|
|
183
241
|
// lands in a dispatch round — after the concurrent siblings in that same round
|
|
184
242
|
// have completed and been appended. Mirrors the clean-stop terminal path.
|
|
185
|
-
const stopOnDenial = async (
|
|
243
|
+
const stopOnDenial = async (index) => {
|
|
186
244
|
settleTranscript();
|
|
187
|
-
await emit({ type: "iteration", source, index
|
|
188
|
-
recordAgentRun({ agentId:
|
|
245
|
+
await emit({ type: "iteration", source, index, final: true });
|
|
246
|
+
recordAgentRun({ agentId: metricAgentId, iterations, cappedOut: false });
|
|
189
247
|
logFinish("warn", "denied", false);
|
|
248
|
+
await giveRound();
|
|
190
249
|
return { messages, finish: { reason: "denied", cappedOut: false, truncationRecoveries, ...finishUsage() } };
|
|
191
250
|
};
|
|
192
251
|
/**
|
|
@@ -243,152 +302,306 @@ async function runAgentLoop(agent, initial, session, opts) {
|
|
|
243
302
|
}
|
|
244
303
|
return errored;
|
|
245
304
|
};
|
|
246
|
-
|
|
305
|
+
// The calls of each tool that passed the mask and the budget in this run.
|
|
306
|
+
const used = new Map();
|
|
307
|
+
/**
|
|
308
|
+
* Dispatch one round under the run's mask and budget. A refused call still
|
|
309
|
+
* gets its own step slot, with an error result instead of running.
|
|
310
|
+
*/
|
|
311
|
+
const dispatchRound = (calls, mask, names) => {
|
|
312
|
+
const refusals = refusalsFor(calls, mask, opts.toolBudget, used);
|
|
313
|
+
return dispatchTools(calls, refusals, toolsById, (tu) => toolCtx(tu, names), isFatalLoopError, callStep, names.tool, encoding, cut);
|
|
314
|
+
};
|
|
315
|
+
const stopOnResolved = async (index) => {
|
|
247
316
|
settleTranscript();
|
|
248
|
-
await emit({ type: "iteration", source, index
|
|
249
|
-
recordAgentRun({ agentId:
|
|
317
|
+
await emit({ type: "iteration", source, index, final: true });
|
|
318
|
+
recordAgentRun({ agentId: metricAgentId, iterations, cappedOut: false });
|
|
319
|
+
await giveRound();
|
|
250
320
|
return { messages, finish: { reason: "stop", cappedOut: false, truncationRecoveries } };
|
|
251
321
|
};
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
const
|
|
262
|
-
const
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
322
|
+
const endOnRefusal = async (refusal) => {
|
|
323
|
+
settleTranscript();
|
|
324
|
+
recordAgentRun({ agentId: metricAgentId, iterations, cappedOut: false });
|
|
325
|
+
logFinish("warn", "error", false);
|
|
326
|
+
await giveRound();
|
|
327
|
+
return { messages, finish: { reason: "error", cappedOut: false, truncationRecoveries, ...finishUsage() }, refusal };
|
|
328
|
+
};
|
|
329
|
+
/** The result of the run when an abort ends the exchange, or `undefined` when the run continues. */
|
|
330
|
+
const compact = async (active, tokensBefore, index) => {
|
|
331
|
+
const id = randomUUID();
|
|
332
|
+
const stepNamespace = `compaction-${compactions++}`;
|
|
333
|
+
const startedAt = performance.now();
|
|
334
|
+
const emitPart = (data) => emit({ type: "data-compaction", source, data });
|
|
335
|
+
await emitPart({ id, status: "running", tokensBefore });
|
|
336
|
+
const { onRound: _onRound, compaction: _compaction, ...conversationOptions } = opts;
|
|
337
|
+
let settled = false;
|
|
338
|
+
try {
|
|
339
|
+
const exchange = await continueAgent(agent, viewOf(), {
|
|
340
|
+
text: active.request,
|
|
341
|
+
mask: active.mask,
|
|
342
|
+
maxRequests: COMPACTION_MAX_REQUESTS,
|
|
343
|
+
stepNamespace,
|
|
344
|
+
accountingAgentId: `${agent.id}-compaction`,
|
|
345
|
+
endOnRequestRefusal: true,
|
|
346
|
+
}, session, { ...conversationOptions, provider: active.provider, turnUsage });
|
|
347
|
+
messages.push(...exchange.messages.map((message) => markCompactionExchange(message, id)));
|
|
348
|
+
await giveRound();
|
|
349
|
+
if (exchange.finish.reason === "aborted") {
|
|
350
|
+
settled = true;
|
|
351
|
+
await emitPart({ id, status: "failed", tokensBefore, durationMs: Math.round(performance.now() - startedAt) });
|
|
352
|
+
settleTranscript();
|
|
353
|
+
await emit({ type: "iteration", source, index, final: true });
|
|
354
|
+
recordAgentRun({ agentId: metricAgentId, iterations, cappedOut: false });
|
|
355
|
+
logFinish("info", "aborted", false);
|
|
356
|
+
await giveRound();
|
|
357
|
+
return { messages, finish: { reason: "aborted", cappedOut: false, truncationRecoveries, ...finishUsage() } };
|
|
358
|
+
}
|
|
359
|
+
const summary = summaryOf(exchange);
|
|
360
|
+
const keptTurns = summary === undefined ? keptTurnsForDrop(messages, viewOptions, active.budget) : 0;
|
|
361
|
+
const markerOf = (tokensAfter, durationMs) => summary === undefined
|
|
362
|
+
? dropMarkerMessage({ kind: "drop", id, tokensBefore, tokensAfter, durationMs, keptTurns })
|
|
363
|
+
: summaryMarkerMessage(summary, { kind: "summary", id, tokensBefore, tokensAfter, durationMs });
|
|
364
|
+
// The figures of a marker do not change the view, thus a draft marker gives the view of the real one.
|
|
365
|
+
const view = conversationView([...messages, markerOf(0, 0)], viewOptions).messages;
|
|
366
|
+
const records = await active.recordsAfter(view);
|
|
367
|
+
const tokensAfter = viewTokens([...view, ...records]);
|
|
368
|
+
const durationMs = Math.round(performance.now() - startedAt);
|
|
369
|
+
messages.push(markerOf(tokensAfter, durationMs), ...records);
|
|
370
|
+
await giveRound();
|
|
371
|
+
settled = true;
|
|
372
|
+
await emitPart({ id, status: summary === undefined ? "failed" : "done", tokensBefore, tokensAfter, durationMs });
|
|
373
|
+
const fields = { compactionId: id, tokensBefore, tokensAfter, durationMs };
|
|
374
|
+
if (summary === undefined) {
|
|
375
|
+
const refusal = exchange.refusal === undefined ? {} : { status: exchange.refusal.status, providerError: exchange.refusal.message };
|
|
376
|
+
log.warn("compaction gave no summary, thus the oldest turns left the view", { ...fields, keptTurns, ...refusal });
|
|
377
|
+
}
|
|
378
|
+
else {
|
|
379
|
+
log.info("conversation compacted", fields);
|
|
380
|
+
}
|
|
381
|
+
if (summary !== undefined && tokensAfter > active.budget) {
|
|
382
|
+
log.warn("the compacted view still exceeds the budget, thus the run compacts no more", {
|
|
383
|
+
compactionId: id,
|
|
384
|
+
tokensAfter,
|
|
385
|
+
budget: active.budget,
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
if (summary === undefined || tokensAfter > active.budget)
|
|
389
|
+
compactionStopped = true;
|
|
390
|
+
return undefined;
|
|
275
391
|
}
|
|
276
|
-
|
|
277
|
-
|
|
392
|
+
finally {
|
|
393
|
+
if (!settled)
|
|
394
|
+
await emitPart({ id, status: "failed", tokensBefore, durationMs: Math.round(performance.now() - startedAt) });
|
|
278
395
|
}
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
396
|
+
};
|
|
397
|
+
const compactOverBudget = async (index) => {
|
|
398
|
+
if (policy === undefined || continuesTruncation || compactionStopped)
|
|
399
|
+
return undefined;
|
|
400
|
+
const tokensBefore = viewTokens(viewOf());
|
|
401
|
+
return tokensBefore > policy.budget ? compact(policy, tokensBefore, index) : undefined;
|
|
402
|
+
};
|
|
403
|
+
const runSegment = async (segment) => {
|
|
404
|
+
const names = segment.stepNames;
|
|
405
|
+
if (segment.requestText !== undefined)
|
|
406
|
+
messages.push(syntheticUserMessage(segment.requestText));
|
|
407
|
+
for (let k = 0; k < segment.maxRequests; k++) {
|
|
408
|
+
const index = segment.firstIndex + k;
|
|
409
|
+
// The last request of a segment that closes a capped run ends the run,
|
|
410
|
+
// whatever its reply, thus its `iteration` event is the final one.
|
|
411
|
+
const endsRun = segment.closesCappedRun === true && k === segment.maxRequests - 1;
|
|
412
|
+
await giveRound();
|
|
413
|
+
if (segment.checksBudget === true) {
|
|
414
|
+
const ended = await compactOverBudget(index);
|
|
415
|
+
if (ended !== undefined)
|
|
416
|
+
return ended;
|
|
417
|
+
}
|
|
418
|
+
if (segment.closesCappedRun !== true)
|
|
419
|
+
iterations++;
|
|
420
|
+
const roundStart = messages.length;
|
|
421
|
+
const called = await callModel(names.llm(k), segment.endsOnRequestRefusal === true);
|
|
422
|
+
continuesTruncation = false;
|
|
423
|
+
if ("refusal" in called)
|
|
424
|
+
return endOnRefusal(called.refusal);
|
|
425
|
+
const reply = called;
|
|
426
|
+
if (reply.finishReason === "aborted") {
|
|
427
|
+
// An interrupted turn keeps whatever the model produced before the cut, but
|
|
428
|
+
// never an empty shell: a partial with no content adds no message. Only the
|
|
429
|
+
// partial carries the marker, because the sink can hold each earlier message.
|
|
430
|
+
// "aborted" is not "tool-calls", so this falls into the terminal return below.
|
|
431
|
+
if (assistantHasContent(reply.message))
|
|
432
|
+
messages.push(reply.message);
|
|
433
|
+
markLastLoopAssistant(messages, roundStart);
|
|
434
|
+
}
|
|
435
|
+
else {
|
|
436
|
+
messages.push(reply.message);
|
|
437
|
+
}
|
|
438
|
+
const toolCalls = toolCallParts(reply.message);
|
|
439
|
+
if (reply.finishReason === "length") {
|
|
440
|
+
truncationRecoveries++;
|
|
441
|
+
await emit({ type: "iteration", source, index, final: endsRun });
|
|
442
|
+
// `tools` is what the model asked for; the trailing call was cut off at the
|
|
443
|
+
// output limit and is never dispatched, which the distinct message records.
|
|
444
|
+
log.debug("iteration truncated at output limit", { iteration: index, tools: toolCalls.map((t) => t.toolName), truncationRecoveries });
|
|
445
|
+
if (toolCalls.length === 0) {
|
|
446
|
+
// Stamped synthetic, not left as a bare `user` message: the wire format needs a user turn
|
|
447
|
+
// after a truncated assistant message, but this one is the loop's own nudge, and thread
|
|
448
|
+
// storage treats a genuine `user` message as the start of a conversation turn. Unmarked, it
|
|
449
|
+
// would split this turn in two everywhere that boundary is read.
|
|
450
|
+
messages.push(syntheticUserMessage(TRUNCATED_PROSE_STEER));
|
|
451
|
+
continuesTruncation = true;
|
|
452
|
+
continue;
|
|
453
|
+
}
|
|
454
|
+
const trailing = toolCalls[toolCalls.length - 1];
|
|
455
|
+
const earlier = toolCalls.slice(0, -1);
|
|
456
|
+
const earlierDetails = roundDetails(earlier);
|
|
457
|
+
for (const [idx, tu] of earlier.entries()) {
|
|
458
|
+
await emit({
|
|
459
|
+
type: "tool-started",
|
|
460
|
+
source,
|
|
461
|
+
toolUseId: tu.toolCallId,
|
|
462
|
+
name: tu.toolName,
|
|
463
|
+
input: tu.input,
|
|
464
|
+
...detailField(earlierDetails[idx]),
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
const { results, durations, resultDetails } = await dispatchRound(earlier, segment.mask, names);
|
|
468
|
+
const errored = await settleRound(earlier, results, earlierDetails, resultDetails, durations);
|
|
469
|
+
results.push(errorResult(trailing, TRUNCATED_TOOL_USE_ERROR));
|
|
470
|
+
// The trailing call was never dispatched, but it reaches the model as an error
|
|
471
|
+
// result like any other — so it counts, or `toolErrors` reports a cleaner run
|
|
472
|
+
// than the model actually read. It gets no `tool-finished` event: no
|
|
473
|
+
// `tool-started` was emitted for it either, and the pair must stay balanced.
|
|
474
|
+
toolCallCount++;
|
|
475
|
+
toolErrorCount++;
|
|
476
|
+
errored.push(trailing.toolName);
|
|
477
|
+
log.debug("tool results returned errors", { iteration: index, tools: errored });
|
|
478
|
+
messages.push({ role: "tool", content: results });
|
|
479
|
+
appendDeferredImages(messages, results, encoding.deferredImages);
|
|
480
|
+
if (hasDenial(results))
|
|
481
|
+
return stopOnDenial(index);
|
|
482
|
+
if (opts.resolved?.())
|
|
483
|
+
return stopOnResolved(index);
|
|
292
484
|
continue;
|
|
293
485
|
}
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
486
|
+
// A local model behind an OpenAI-compatible server routinely labels a
|
|
487
|
+
// tool-calling reply `stop`. The calls are complete and the model waits
|
|
488
|
+
// on their results, thus the mislabeled round dispatches like a
|
|
489
|
+
// `tool-calls` one. Every other terminal answers its calls with the
|
|
490
|
+
// not-run result: a filtered or aborted reply must not act.
|
|
491
|
+
const dispatchesRound = reply.finishReason === "tool-calls" || (reply.finishReason === "stop" && toolCalls.length > 0);
|
|
492
|
+
if (!dispatchesRound) {
|
|
493
|
+
settleTranscript();
|
|
494
|
+
await emit({ type: "iteration", source, index, final: true });
|
|
495
|
+
if (segment.closesCappedRun === true) {
|
|
496
|
+
// The cap was already reached; `reason` still distinguishes an
|
|
497
|
+
// abort (user cut the turn) from a plain `max_iterations` cap-out.
|
|
498
|
+
const reason = reply.finishReason === "aborted" ? "aborted" : "max_iterations";
|
|
499
|
+
recordAgentRun({ agentId: metricAgentId, iterations, cappedOut: true });
|
|
500
|
+
logFinish("warn", reason, true);
|
|
501
|
+
await giveRound();
|
|
502
|
+
return { messages, finish: { reason, cappedOut: true, truncationRecoveries, ...finishUsage() } };
|
|
503
|
+
}
|
|
504
|
+
recordAgentRun({ agentId: metricAgentId, iterations, cappedOut: false });
|
|
505
|
+
logFinish("info", reply.finishReason, false);
|
|
506
|
+
await giveRound();
|
|
507
|
+
return {
|
|
508
|
+
messages,
|
|
509
|
+
finish: {
|
|
510
|
+
reason: reply.finishReason,
|
|
511
|
+
...(reply.rawFinishReason === undefined ? {} : { rawFinishReason: reply.rawFinishReason }),
|
|
512
|
+
cappedOut: false,
|
|
513
|
+
truncationRecoveries,
|
|
514
|
+
...finishUsage(),
|
|
515
|
+
},
|
|
516
|
+
};
|
|
299
517
|
}
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
log.debug("tool results returned errors", { iteration: i, tools: errored });
|
|
518
|
+
await emit({ type: "iteration", source, index, final: endsRun });
|
|
519
|
+
log.debug("iteration", { iteration: index, tools: toolCalls.map((t) => t.toolName) });
|
|
520
|
+
const details = roundDetails(toolCalls);
|
|
521
|
+
for (const [idx, tu] of toolCalls.entries()) {
|
|
522
|
+
await emit({ type: "tool-started", source, toolUseId: tu.toolCallId, name: tu.toolName, input: tu.input, ...detailField(details[idx]) });
|
|
523
|
+
}
|
|
524
|
+
const { results, durations, resultDetails } = await dispatchRound(toolCalls, segment.mask, names);
|
|
525
|
+
const errored = await settleRound(toolCalls, results, details, resultDetails, durations);
|
|
526
|
+
if (errored.length > 0)
|
|
527
|
+
log.debug("tool results returned errors", { iteration: index, tools: errored });
|
|
311
528
|
messages.push({ role: "tool", content: results });
|
|
312
529
|
appendDeferredImages(messages, results, encoding.deferredImages);
|
|
313
530
|
if (hasDenial(results))
|
|
314
|
-
return stopOnDenial(
|
|
531
|
+
return stopOnDenial(index);
|
|
315
532
|
if (opts.resolved?.())
|
|
316
|
-
return stopOnResolved(
|
|
317
|
-
continue;
|
|
533
|
+
return stopOnResolved(index);
|
|
318
534
|
}
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
logFinish("info", reply.finishReason, false);
|
|
335
|
-
return {
|
|
336
|
-
messages,
|
|
337
|
-
finish: {
|
|
338
|
-
reason: reply.finishReason,
|
|
339
|
-
...(reply.rawFinishReason === undefined ? {} : { rawFinishReason: reply.rawFinishReason }),
|
|
340
|
-
cappedOut: false,
|
|
341
|
-
truncationRecoveries,
|
|
342
|
-
...finishUsage(),
|
|
343
|
-
},
|
|
344
|
-
};
|
|
535
|
+
return "capped";
|
|
536
|
+
};
|
|
537
|
+
const endCapped = async () => {
|
|
538
|
+
settleTranscript();
|
|
539
|
+
recordAgentRun({ agentId: metricAgentId, iterations, cappedOut: true });
|
|
540
|
+
logFinish("warn", "max_iterations", true);
|
|
541
|
+
await giveRound();
|
|
542
|
+
return { messages, finish: { reason: "max_iterations", cappedOut: true, truncationRecoveries, ...finishUsage() } };
|
|
543
|
+
};
|
|
544
|
+
// A throw keeps the open round, and the not-run results keep that stored round valid on the wire.
|
|
545
|
+
const giveRoundBeforeThrow = async (body) => {
|
|
546
|
+
if (opts.onRound === undefined)
|
|
547
|
+
return body();
|
|
548
|
+
try {
|
|
549
|
+
return await body();
|
|
345
550
|
}
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
551
|
+
catch (err) {
|
|
552
|
+
if (sinkPending)
|
|
553
|
+
throw err;
|
|
554
|
+
answerUnansweredToolCalls(messages, givenCount);
|
|
555
|
+
// A rejection of this call must not hide the error of the run.
|
|
556
|
+
await giveRound().catch(() => undefined);
|
|
557
|
+
throw err;
|
|
351
558
|
}
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
559
|
+
};
|
|
560
|
+
return {
|
|
561
|
+
runSegment: (segment) => giveRoundBeforeThrow(() => runSegment(segment)),
|
|
562
|
+
endCapped: () => giveRoundBeforeThrow(endCapped),
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
/** The statuses of a request that the provider refuses for its content, for example a request past the context window. */
|
|
566
|
+
const REFUSAL_STATUSES = new Set([400, 413]);
|
|
567
|
+
function requestRefusalOf(error) {
|
|
568
|
+
if (error.type !== "provider")
|
|
569
|
+
return undefined;
|
|
570
|
+
const status = extractStatus(error);
|
|
571
|
+
return status !== undefined && REFUSAL_STATUSES.has(status) ? { status, message: error.message } : undefined;
|
|
572
|
+
}
|
|
573
|
+
/** The text of the last reply of an exchange, when that reply ends the exchange with the finish reason `stop`. */
|
|
574
|
+
function summaryOf(exchange) {
|
|
575
|
+
if (exchange.finish.reason !== "stop")
|
|
576
|
+
return undefined;
|
|
577
|
+
const text = finalText(exchange.messages).trim();
|
|
578
|
+
return text.length > 0 ? text : undefined;
|
|
579
|
+
}
|
|
580
|
+
/**
|
|
581
|
+
* Called at the fold point, before any run-ending branch, so an aborted or
|
|
582
|
+
* dead run is still accounted. Token counters grow inside the call's step
|
|
583
|
+
* body, not here, because a replay re-delivers the stored reply to this
|
|
584
|
+
* function too; the record re-sends under the same idempotency key, which an
|
|
585
|
+
* upserting sink counts once.
|
|
586
|
+
*/
|
|
587
|
+
export function accountForChatCall(reply, call) {
|
|
588
|
+
for (const rollup of call.rollups)
|
|
589
|
+
addChatUsage(rollup, reply.usage);
|
|
590
|
+
const reported = reply.usage;
|
|
591
|
+
if (reported === undefined || !hasReportedUsage(reported))
|
|
592
|
+
return;
|
|
593
|
+
const { session } = call;
|
|
594
|
+
void deliverNotice(call.logger, "UsageRecorder.record", call.usageRecorder.record({
|
|
595
|
+
recordKey: recordKeyFor(session, call.invocationId, call.stepName),
|
|
596
|
+
agentId: call.agentId,
|
|
597
|
+
callPath: call.callPath,
|
|
598
|
+
scope: session.scope,
|
|
599
|
+
...(session.runFrame?.runId === undefined ? {} : { runId: session.runFrame.runId }),
|
|
600
|
+
...(session.runFrame?.stepId === undefined ? {} : { stepId: session.runFrame.stepId }),
|
|
601
|
+
...(reply.requestedModelId === undefined ? {} : { requestedModelId: reply.requestedModelId }),
|
|
602
|
+
...(reply.servedModelId === undefined ? {} : { servedModelId: reply.servedModelId }),
|
|
603
|
+
usage: reported,
|
|
604
|
+
}));
|
|
392
605
|
}
|
|
393
606
|
/**
|
|
394
607
|
* Delimiter joining the call path into one key segment.
|
|
@@ -398,11 +611,13 @@ async function runAgentLoop(agent, initial, session, opts) {
|
|
|
398
611
|
* UUIDs or the profiler's `data-profile` literal, step ids are path-safe plan
|
|
399
612
|
* ids (`T1S1`, `synthesis`, `profile`), tool-call ids are provider-issued
|
|
400
613
|
* opaque tokens, and the loop's step names are `llm-{n}` / `tool-{name}-{id}`
|
|
401
|
-
* with an optional `salvage
|
|
614
|
+
* with an optional continuation namespace (`salvage:`, `file-metadata:`,
|
|
615
|
+
* `step-summary:`) — none of them can contain `>`.
|
|
402
616
|
*/
|
|
403
617
|
const CALL_PATH_DELIMITER = ">";
|
|
404
618
|
/**
|
|
405
|
-
* Idempotency key for one call's usage record
|
|
619
|
+
* Idempotency key for one call's usage record, and for the kept text of one cut
|
|
620
|
+
* tool result under the tool step name of its call:
|
|
406
621
|
* `{runId}[:{stepId}]:{callPath}[:{invocationId}]:{stepName}`.
|
|
407
622
|
*
|
|
408
623
|
* Every component is a fact the same call carries again on every replay, so a
|
|
@@ -452,14 +667,11 @@ function assistantHasContent(message) {
|
|
|
452
667
|
return message.content.length > 0;
|
|
453
668
|
}
|
|
454
669
|
/**
|
|
455
|
-
*
|
|
456
|
-
*
|
|
457
|
-
* a marked copy so the mark rides into `appendTurn` and the stored row. When the
|
|
458
|
-
* turn produced no assistant message beyond `initial` (a no-output abort on a
|
|
459
|
-
* fresh turn), there is nothing to mark and the transcript is left untouched.
|
|
670
|
+
* Mark the aborted partial of the last request. A message before `roundStart` stays unmarked,
|
|
671
|
+
* because the round sink can hold it already.
|
|
460
672
|
*/
|
|
461
|
-
function markLastLoopAssistant(messages,
|
|
462
|
-
for (let idx = messages.length - 1; idx >=
|
|
673
|
+
function markLastLoopAssistant(messages, roundStart) {
|
|
674
|
+
for (let idx = messages.length - 1; idx >= roundStart; idx--) {
|
|
463
675
|
const message = messages[idx];
|
|
464
676
|
if (message.role === "assistant") {
|
|
465
677
|
messages[idx] = markInterruptedMessage(message);
|
|
@@ -527,19 +739,27 @@ function appendDeferredImages(messages, results, deferredImages) {
|
|
|
527
739
|
/**
|
|
528
740
|
* Dispatch one round of tool calls, and measure the time of each call.
|
|
529
741
|
*
|
|
530
|
-
* `results`, `durations` and `resultDetails` are positionally aligned
|
|
742
|
+
* `refusals`, `results`, `durations` and `resultDetails` are positionally aligned
|
|
743
|
+
* with `toolUses`; `refusals[i]` is the refusal text, or `undefined` if the call runs.
|
|
744
|
+
*
|
|
745
|
+
* A refused call still runs inside the same step wrapper and step name as a
|
|
746
|
+
* dispatched one, keeping a round's step sequence identical for replay.
|
|
531
747
|
*
|
|
532
748
|
* Each measurement brackets the same unit that the loop awaits for that call. For a
|
|
533
749
|
* step-mode call that unit is `runStep`, thus the figure includes the durable-step
|
|
534
750
|
* wrapper. The wrapper is part of what the call cost, and a cached replay of a step
|
|
535
751
|
* is genuinely fast. A bracket inside the step would report a body that did not run.
|
|
536
752
|
*/
|
|
537
|
-
async function dispatchTools(toolUses, toolsById, toolCtx, isFatalLoopError, runStep, toolStepName, encoding) {
|
|
538
|
-
|
|
753
|
+
async function dispatchTools(toolUses, refusals, toolsById, toolCtx, isFatalLoopError, runStep, toolStepName, encoding, cut) {
|
|
754
|
+
// A refusal writes no tool span, because the tool does not run.
|
|
755
|
+
const dispatch = (tu, refusal) => refusal !== undefined
|
|
756
|
+
? Promise.resolve({ result: errorResult(tu, refusal) })
|
|
757
|
+
: traceToolCall(tu, () => dispatchTool(tu, toolsById, toolCtx(tu), isFatalLoopError, encoding, cut, toolStepName(tu.toolName, tu.toolCallId)), (dispatched) => outcomeOf(dispatched.result));
|
|
539
758
|
const results = new Array(toolUses.length);
|
|
540
759
|
// The array starts with holes, which read as `undefined`. The element type
|
|
541
|
-
// says so, thus it agrees with what `settleRound` accepts. Every index is
|
|
542
|
-
//
|
|
760
|
+
// says so, thus it agrees with what `settleRound` accepts. Every index is
|
|
761
|
+
// assigned except the index of a refused call, because the mode partition
|
|
762
|
+
// below covers each call.
|
|
543
763
|
const durations = new Array(toolUses.length);
|
|
544
764
|
// A hole stays a hole for a call that described no result: an error, a
|
|
545
765
|
// denial, and a tool with no result hook each leave the index unassigned,
|
|
@@ -558,30 +778,27 @@ async function dispatchTools(toolUses, toolsById, toolCtx, isFatalLoopError, run
|
|
|
558
778
|
stepTools.push({ tu, idx });
|
|
559
779
|
}
|
|
560
780
|
await Promise.all(stepTools.map(({ tu, idx }) => {
|
|
781
|
+
const refusal = refusals[idx];
|
|
561
782
|
const startedAt = performance.now();
|
|
562
|
-
return runStep(toolStepName(tu.toolName, tu.toolCallId), () => dispatch(tu)).then((settled) => {
|
|
563
|
-
|
|
783
|
+
return runStep(toolStepName(tu.toolName, tu.toolCallId), () => dispatch(tu, refusal)).then((settled) => {
|
|
784
|
+
if (refusal === undefined)
|
|
785
|
+
durations[idx] = elapsedMs(startedAt);
|
|
564
786
|
const dispatched = readDispatchedStep(settled);
|
|
565
787
|
results[idx] = dispatched.result;
|
|
566
788
|
if (dispatched.detail !== undefined)
|
|
567
789
|
resultDetails[idx] = dispatched.detail;
|
|
568
790
|
});
|
|
569
791
|
}));
|
|
570
|
-
|
|
792
|
+
// A workflow-mode and an inline-mode call run unwrapped, one after another.
|
|
793
|
+
for (const { tu, idx } of [...workflowTools, ...inlineTools]) {
|
|
794
|
+
const refusal = refusals[idx];
|
|
571
795
|
const startedAt = performance.now();
|
|
572
|
-
const dispatched = await dispatch(tu);
|
|
796
|
+
const dispatched = await dispatch(tu, refusal);
|
|
573
797
|
results[idx] = dispatched.result;
|
|
574
798
|
if (dispatched.detail !== undefined)
|
|
575
799
|
resultDetails[idx] = dispatched.detail;
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
for (const { tu, idx } of inlineTools) {
|
|
579
|
-
const startedAt = performance.now();
|
|
580
|
-
const dispatched = await dispatch(tu);
|
|
581
|
-
results[idx] = dispatched.result;
|
|
582
|
-
if (dispatched.detail !== undefined)
|
|
583
|
-
resultDetails[idx] = dispatched.detail;
|
|
584
|
-
durations[idx] = elapsedMs(startedAt);
|
|
800
|
+
if (refusal === undefined)
|
|
801
|
+
durations[idx] = elapsedMs(startedAt);
|
|
585
802
|
}
|
|
586
803
|
return { results, durations, resultDetails };
|
|
587
804
|
}
|
|
@@ -602,7 +819,37 @@ function elapsedMs(startedAt) {
|
|
|
602
819
|
function readDispatchedStep(settled) {
|
|
603
820
|
return "result" in settled ? settled : { result: settled };
|
|
604
821
|
}
|
|
605
|
-
|
|
822
|
+
/** For a step-mode tool this runs inside the durable step of the call, thus a replay gives the stored excerpt. */
|
|
823
|
+
async function dispatchTool(tu, toolsById, ctx, isFatalLoopError, encoding, cut, stepName) {
|
|
824
|
+
const dispatched = await callTool(tu, toolsById, ctx, isFatalLoopError, encoding);
|
|
825
|
+
return { ...dispatched, result: await cutLongResult(dispatched.result, cut, stepName) };
|
|
826
|
+
}
|
|
827
|
+
/** The excerpt does not depend on the outcome of the put, thus a replay gives the same bytes. */
|
|
828
|
+
async function cutLongResult(result, cut, stepName) {
|
|
829
|
+
const text = resultTextOf(result.output);
|
|
830
|
+
if (text === undefined || text.length <= TOOL_RESULT_CAP)
|
|
831
|
+
return result;
|
|
832
|
+
if (cut.store === undefined)
|
|
833
|
+
return withExcerpt(result, excerptOf(text, undefined));
|
|
834
|
+
const { session } = cut;
|
|
835
|
+
const ref = toolOutputRef(recordKeyFor(session, cut.invocationId, stepName));
|
|
836
|
+
const content = keptTextOf(text);
|
|
837
|
+
const put = await cut.store.put({
|
|
838
|
+
analysisId: session.scope.analysisId,
|
|
839
|
+
ref,
|
|
840
|
+
toolName: result.toolName,
|
|
841
|
+
toolCallId: result.toolCallId,
|
|
842
|
+
// A run session carries the thread of the chat that started the run, but a run belongs to its analysis.
|
|
843
|
+
...(session.runFrame === undefined && session.scope.threadId !== undefined ? { threadId: session.scope.threadId } : {}),
|
|
844
|
+
content,
|
|
845
|
+
totalLength: text.length,
|
|
846
|
+
});
|
|
847
|
+
if (put.isErr()) {
|
|
848
|
+
cut.log.warn("tool output not kept", { toolName: result.toolName, toolCallId: result.toolCallId, ref, errorType: put.error.type });
|
|
849
|
+
}
|
|
850
|
+
return withExcerpt(result, excerptOf(text, { ref, keptLength: content.length }));
|
|
851
|
+
}
|
|
852
|
+
async function callTool(tu, toolsById, ctx, isFatalLoopError, encoding) {
|
|
606
853
|
const tool = toolsById.get(tu.toolName);
|
|
607
854
|
if (tool === undefined) {
|
|
608
855
|
labelToolFailure("unknown_tool");
|