@adonis-agora/agent 0.30.2 → 0.33.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/CHANGELOG.md +866 -0
- package/dist/assets/spa/assets/{index-Cfi3g3b2.js → index-3Q2m5_Ax.js} +1 -1
- package/dist/assets/spa/index.html +1 -1
- package/dist/providers/agent_provider.d.ts.map +1 -1
- package/dist/providers/agent_provider.js +157 -9
- package/dist/providers/agent_provider.js.map +1 -1
- package/dist/src/agent-deps-factory.d.ts +16 -2
- package/dist/src/agent-deps-factory.d.ts.map +1 -1
- package/dist/src/agent-deps-factory.js +8 -0
- package/dist/src/agent-deps-factory.js.map +1 -1
- package/dist/src/agent-deps.d.ts +21 -1
- package/dist/src/agent-deps.d.ts.map +1 -1
- package/dist/src/agent-deps.js.map +1 -1
- package/dist/src/agent-loop.d.ts +226 -9
- package/dist/src/agent-loop.d.ts.map +1 -1
- package/dist/src/agent-loop.js +1311 -208
- package/dist/src/agent-loop.js.map +1 -1
- package/dist/src/agent-service.d.ts +27 -0
- package/dist/src/agent-service.d.ts.map +1 -1
- package/dist/src/agent-service.js +29 -0
- package/dist/src/agent-service.js.map +1 -1
- package/dist/src/ai-sdk/ai-sdk-model.d.ts.map +1 -1
- package/dist/src/ai-sdk/ai-sdk-model.js +21 -1
- package/dist/src/ai-sdk/ai-sdk-model.js.map +1 -1
- package/dist/src/client/sse.d.ts +32 -2
- package/dist/src/client/sse.d.ts.map +1 -1
- package/dist/src/client/sse.js +45 -2
- package/dist/src/client/sse.js.map +1 -1
- package/dist/src/define_config.d.ts +41 -7
- package/dist/src/define_config.d.ts.map +1 -1
- package/dist/src/define_config.js.map +1 -1
- package/dist/src/durable/agent-run-workflow.d.ts +10 -11
- package/dist/src/durable/agent-run-workflow.d.ts.map +1 -1
- package/dist/src/durable/agent-run-workflow.js +49 -21
- package/dist/src/durable/agent-run-workflow.js.map +1 -1
- package/dist/src/durable/durable-agent-runner.d.ts +3 -2
- package/dist/src/durable/durable-agent-runner.d.ts.map +1 -1
- package/dist/src/durable/durable-agent-runner.js +2 -2
- package/dist/src/durable/durable-agent-runner.js.map +1 -1
- package/dist/src/elicitation.d.ts +207 -0
- package/dist/src/elicitation.d.ts.map +1 -0
- package/dist/src/elicitation.js +315 -0
- package/dist/src/elicitation.js.map +1 -0
- package/dist/src/evals/approval-prior.d.ts +58 -0
- package/dist/src/evals/approval-prior.d.ts.map +1 -0
- package/dist/src/evals/approval-prior.js +82 -0
- package/dist/src/evals/approval-prior.js.map +1 -0
- package/dist/src/evals/evaluate.d.ts +51 -0
- package/dist/src/evals/evaluate.d.ts.map +1 -0
- package/dist/src/evals/evaluate.js +76 -0
- package/dist/src/evals/evaluate.js.map +1 -0
- package/dist/src/evals/governance-sample-source.d.ts +23 -0
- package/dist/src/evals/governance-sample-source.d.ts.map +1 -0
- package/dist/src/evals/governance-sample-source.js +93 -0
- package/dist/src/evals/governance-sample-source.js.map +1 -0
- package/dist/src/evals/in-memory-score-store.d.ts +16 -0
- package/dist/src/evals/in-memory-score-store.d.ts.map +1 -0
- package/dist/src/evals/in-memory-score-store.js +57 -0
- package/dist/src/evals/in-memory-score-store.js.map +1 -0
- package/dist/src/evals/index.d.ts +15 -0
- package/dist/src/evals/index.d.ts.map +1 -0
- package/dist/src/evals/index.js +14 -0
- package/dist/src/evals/index.js.map +1 -0
- package/dist/src/evals/judge.d.ts +30 -0
- package/dist/src/evals/judge.d.ts.map +1 -0
- package/dist/src/evals/judge.js +53 -0
- package/dist/src/evals/judge.js.map +1 -0
- package/dist/src/evals/live-scoring.d.ts +42 -0
- package/dist/src/evals/live-scoring.d.ts.map +1 -0
- package/dist/src/evals/live-scoring.js +103 -0
- package/dist/src/evals/live-scoring.js.map +1 -0
- package/dist/src/evals/sample-source.d.ts +40 -0
- package/dist/src/evals/sample-source.d.ts.map +1 -0
- package/dist/src/evals/sample-source.js +33 -0
- package/dist/src/evals/sample-source.js.map +1 -0
- package/dist/src/evals/score-store.d.ts +54 -0
- package/dist/src/evals/score-store.d.ts.map +1 -0
- package/dist/src/evals/score-store.js +2 -0
- package/dist/src/evals/score-store.js.map +1 -0
- package/dist/src/evals/scorers/answer-relevancy.scorer.d.ts +28 -0
- package/dist/src/evals/scorers/answer-relevancy.scorer.d.ts.map +1 -0
- package/dist/src/evals/scorers/answer-relevancy.scorer.js +42 -0
- package/dist/src/evals/scorers/answer-relevancy.scorer.js.map +1 -0
- package/dist/src/evals/scorers/approval-outcome.scorer.d.ts +22 -0
- package/dist/src/evals/scorers/approval-outcome.scorer.d.ts.map +1 -0
- package/dist/src/evals/scorers/approval-outcome.scorer.js +60 -0
- package/dist/src/evals/scorers/approval-outcome.scorer.js.map +1 -0
- package/dist/src/evals/scorers/approval-risk.scorer.d.ts +28 -0
- package/dist/src/evals/scorers/approval-risk.scorer.d.ts.map +1 -0
- package/dist/src/evals/scorers/approval-risk.scorer.js +54 -0
- package/dist/src/evals/scorers/approval-risk.scorer.js.map +1 -0
- package/dist/src/evals/scorers/run-completion.scorer.d.ts +18 -0
- package/dist/src/evals/scorers/run-completion.scorer.d.ts.map +1 -0
- package/dist/src/evals/scorers/run-completion.scorer.js +55 -0
- package/dist/src/evals/scorers/run-completion.scorer.js.map +1 -0
- package/dist/src/evals/summarize.d.ts +44 -0
- package/dist/src/evals/summarize.d.ts.map +1 -0
- package/dist/src/evals/summarize.js +87 -0
- package/dist/src/evals/summarize.js.map +1 -0
- package/dist/src/evals/types.d.ts +81 -0
- package/dist/src/evals/types.d.ts.map +1 -0
- package/dist/src/evals/types.js +15 -0
- package/dist/src/evals/types.js.map +1 -0
- package/dist/src/history-window.d.ts +50 -11
- package/dist/src/history-window.d.ts.map +1 -1
- package/dist/src/history-window.js +84 -12
- package/dist/src/history-window.js.map +1 -1
- package/dist/src/index.d.ts +11 -3
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +10 -3
- package/dist/src/index.js.map +1 -1
- package/dist/src/mcp/server.d.ts +8 -0
- package/dist/src/mcp/server.d.ts.map +1 -1
- package/dist/src/mcp/server.js +27 -1
- package/dist/src/mcp/server.js.map +1 -1
- package/dist/src/mcp-client/importer.d.ts +75 -0
- package/dist/src/mcp-client/importer.d.ts.map +1 -0
- package/dist/src/mcp-client/importer.js +161 -0
- package/dist/src/mcp-client/importer.js.map +1 -0
- package/dist/src/mcp-client/index.d.ts +9 -0
- package/dist/src/mcp-client/index.d.ts.map +1 -0
- package/dist/src/mcp-client/index.js +8 -0
- package/dist/src/mcp-client/index.js.map +1 -0
- package/dist/src/mcp-client/options.d.ts +99 -0
- package/dist/src/mcp-client/options.d.ts.map +1 -0
- package/dist/src/mcp-client/options.js +2 -0
- package/dist/src/mcp-client/options.js.map +1 -0
- package/dist/src/mcp-client/tool-input.d.ts +39 -0
- package/dist/src/mcp-client/tool-input.d.ts.map +1 -0
- package/dist/src/mcp-client/tool-input.js +61 -0
- package/dist/src/mcp-client/tool-input.js.map +1 -0
- package/dist/src/mcp-client/tool-kind.d.ts +37 -0
- package/dist/src/mcp-client/tool-kind.d.ts.map +1 -0
- package/dist/src/mcp-client/tool-kind.js +20 -0
- package/dist/src/mcp-client/tool-kind.js.map +1 -0
- package/dist/src/mcp-client/tool-name.d.ts +22 -0
- package/dist/src/mcp-client/tool-name.d.ts.map +1 -0
- package/dist/src/mcp-client/tool-name.js +35 -0
- package/dist/src/mcp-client/tool-name.js.map +1 -0
- package/dist/src/mcp-client/tool-source.d.ts +75 -0
- package/dist/src/mcp-client/tool-source.d.ts.map +1 -0
- package/dist/src/mcp-client/tool-source.js +244 -0
- package/dist/src/mcp-client/tool-source.js.map +1 -0
- package/dist/src/mcp-client/transient.d.ts +28 -0
- package/dist/src/mcp-client/transient.d.ts.map +1 -0
- package/dist/src/mcp-client/transient.js +126 -0
- package/dist/src/mcp-client/transient.js.map +1 -0
- package/dist/src/mcp-client/unsafe-regex.d.ts +23 -0
- package/dist/src/mcp-client/unsafe-regex.d.ts.map +1 -0
- package/dist/src/mcp-client/unsafe-regex.js +194 -0
- package/dist/src/mcp-client/unsafe-regex.js.map +1 -0
- package/dist/src/memory.d.ts +448 -0
- package/dist/src/memory.d.ts.map +1 -0
- package/dist/src/memory.js +330 -0
- package/dist/src/memory.js.map +1 -0
- package/dist/src/pagination.d.ts +63 -0
- package/dist/src/pagination.d.ts.map +1 -0
- package/dist/src/pagination.js +16 -0
- package/dist/src/pagination.js.map +1 -0
- package/dist/src/processors.d.ts +134 -0
- package/dist/src/processors.d.ts.map +1 -0
- package/dist/src/processors.js +215 -0
- package/dist/src/processors.js.map +1 -0
- package/dist/src/rag/qdrant-store.d.ts +48 -6
- package/dist/src/rag/qdrant-store.d.ts.map +1 -1
- package/dist/src/rag/qdrant-store.js +77 -24
- package/dist/src/rag/qdrant-store.js.map +1 -1
- package/dist/src/replay-integrity.d.ts +17 -0
- package/dist/src/replay-integrity.d.ts.map +1 -0
- package/dist/src/replay-integrity.js +19 -0
- package/dist/src/replay-integrity.js.map +1 -0
- package/dist/src/runners/inline-agent-runner.d.ts +33 -7
- package/dist/src/runners/inline-agent-runner.d.ts.map +1 -1
- package/dist/src/runners/inline-agent-runner.js +113 -27
- package/dist/src/runners/inline-agent-runner.js.map +1 -1
- package/dist/src/skills.d.ts +291 -0
- package/dist/src/skills.d.ts.map +1 -0
- package/dist/src/skills.js +0 -0
- package/dist/src/skills.js.map +1 -0
- package/dist/src/spi/agent-runner.d.ts +20 -6
- package/dist/src/spi/agent-runner.d.ts.map +1 -1
- package/dist/src/spi/agent-store.d.ts +53 -0
- package/dist/src/spi/agent-store.d.ts.map +1 -1
- package/dist/src/spi/governance-queries.d.ts +33 -13
- package/dist/src/spi/governance-queries.d.ts.map +1 -1
- package/dist/src/spi/history-window.d.ts +64 -10
- package/dist/src/spi/history-window.d.ts.map +1 -1
- package/dist/src/spi/model-provider.d.ts +18 -0
- package/dist/src/spi/model-provider.d.ts.map +1 -1
- package/dist/src/spi/processors.d.ts +161 -0
- package/dist/src/spi/processors.d.ts.map +1 -0
- package/dist/src/spi/processors.js +57 -0
- package/dist/src/spi/processors.js.map +1 -0
- package/dist/src/spi/token-stream-sink.d.ts +43 -0
- package/dist/src/spi/token-stream-sink.d.ts.map +1 -1
- package/dist/src/spi/token-stream-sink.js +17 -1
- package/dist/src/spi/token-stream-sink.js.map +1 -1
- package/dist/src/sse.d.ts +5 -2
- package/dist/src/sse.d.ts.map +1 -1
- package/dist/src/sse.js +11 -2
- package/dist/src/sse.js.map +1 -1
- package/dist/src/stores/lucid-governance-queries.d.ts +5 -2
- package/dist/src/stores/lucid-governance-queries.d.ts.map +1 -1
- package/dist/src/stores/lucid-governance-queries.js +14 -5
- package/dist/src/stores/lucid-governance-queries.js.map +1 -1
- package/dist/src/stores/lucid-schema.d.ts.map +1 -1
- package/dist/src/stores/lucid-schema.js +15 -0
- package/dist/src/stores/lucid-schema.js.map +1 -1
- package/dist/src/stores/lucid.d.ts +11 -3
- package/dist/src/stores/lucid.d.ts.map +1 -1
- package/dist/src/stores/lucid.js +65 -0
- package/dist/src/stores/lucid.js.map +1 -1
- package/dist/src/structured-output.d.ts +57 -0
- package/dist/src/structured-output.d.ts.map +1 -0
- package/dist/src/structured-output.js +80 -0
- package/dist/src/structured-output.js.map +1 -0
- package/dist/src/telescope/agent-governance-providers.js +2 -2
- package/dist/src/telescope/agent-governance-providers.js.map +1 -1
- package/dist/src/testing/fake-model-provider.d.ts +8 -4
- package/dist/src/testing/fake-model-provider.d.ts.map +1 -1
- package/dist/src/testing/fake-model-provider.js +6 -9
- package/dist/src/testing/fake-model-provider.js.map +1 -1
- package/dist/src/testing/in-memory-governance-queries.d.ts.map +1 -1
- package/dist/src/testing/in-memory-governance-queries.js +8 -3
- package/dist/src/testing/in-memory-governance-queries.js.map +1 -1
- package/dist/src/testing/in-memory-store.d.ts +4 -3
- package/dist/src/testing/in-memory-store.d.ts.map +1 -1
- package/dist/src/testing/in-memory-store.js +13 -7
- package/dist/src/testing/in-memory-store.js.map +1 -1
- package/dist/src/testing/index.d.ts +1 -1
- package/dist/src/testing/index.d.ts.map +1 -1
- package/dist/src/testing/index.js.map +1 -1
- package/dist/src/tool-registry.d.ts +9 -0
- package/dist/src/tool-registry.d.ts.map +1 -1
- package/dist/src/tool-registry.js +11 -0
- package/dist/src/tool-registry.js.map +1 -1
- package/dist/src/types.d.ts +55 -2
- package/dist/src/types.d.ts.map +1 -1
- package/package.json +20 -10
- package/skills/agent-personas-agents/SKILL.md +16 -12
package/dist/src/agent-loop.js
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import { publishAgentDelegated, publishAgentMessage, publishAgentRetrieved, publishAgentRunFinished, publishAgentRunStarted, publishAgentToolCall, publishAgentToolRetry, spannedAgent, } from './diagnostics.js';
|
|
2
|
+
import { ASK_TOOL_NAME, askInputSchema, askToolDefinition, DEFAULT_INTAKE_PREAMBLE, isHumanDecision, normalizeElicitationReply, settleElicitation, } from './elicitation.js';
|
|
3
|
+
import { buildMemoryBlock, offerMemories, REMEMBER_TOOL_NAME, rememberInputSchema, rememberToolDefinition, writeMemory, } from './memory.js';
|
|
4
|
+
import { createFrameBuffer, createIncrementalGate, gateTail, releaseGatedFrames, resolveGateLookback, resolveOutputGateMode, runInputProcessors, runOutputProcessors, } from './processors.js';
|
|
5
|
+
import { isReplayIntegrityError } from './replay-integrity.js';
|
|
6
|
+
import { buildSkillsBlock, loadSkill, offerSkills, SKILL_TOOL_NAME, skillInputSchema, skillToolDefinition, } from './skills.js';
|
|
2
7
|
import { estimateCost, resolveModelPrice, } from './spi/pricing-store.js';
|
|
8
|
+
import { OutputRejectedError, } from './spi/processors.js';
|
|
9
|
+
import { DEFAULT_STRUCTURED_OUTPUT_INSTRUCTION, repairInstruction, StructuredOutputError, validateStructured, } from './structured-output.js';
|
|
3
10
|
import { ToolForbiddenError, ToolInputInvalidError } from './tool-registry.js';
|
|
4
11
|
import { invokeWithTransientRetry } from './tool-retry.js';
|
|
5
12
|
/** Renders retrieved passages as a numbered, citable context block appended to the system prompt. */
|
|
@@ -23,6 +30,17 @@ function intersectAllow(a, b) {
|
|
|
23
30
|
const second = new Set(b);
|
|
24
31
|
return a.filter((name) => second.has(name));
|
|
25
32
|
}
|
|
33
|
+
/**
|
|
34
|
+
* The {@link AgentLoopHooks.parallel} implementation for a runner whose checkpoint positions are
|
|
35
|
+
* handed out on the CALL — `@adonis-agora/durable`'s `ctx.localStep` is: it takes `pos.next()`
|
|
36
|
+
* before its first `await`. Every task is invoked here, synchronously and in list order, before any
|
|
37
|
+
* of them is awaited, which is what fixes the block of positions they occupy whatever order they
|
|
38
|
+
* then settle in. Nothing rejects: the caller decides what an individual failure means.
|
|
39
|
+
*/
|
|
40
|
+
export function settleAll(tasks) {
|
|
41
|
+
const started = tasks.map((task) => task());
|
|
42
|
+
return Promise.all(started.map((work) => work.then((value) => ({ ok: true, value }), (error) => ({ ok: false, error }))));
|
|
43
|
+
}
|
|
26
44
|
export class QuotaExceededError extends Error {
|
|
27
45
|
constructor() {
|
|
28
46
|
super('Daily token quota exceeded');
|
|
@@ -60,6 +78,112 @@ function extractTask(input) {
|
|
|
60
78
|
}
|
|
61
79
|
return JSON.stringify(input);
|
|
62
80
|
}
|
|
81
|
+
/**
|
|
82
|
+
* How many agent→agent delegations deep a chain may go when the host names no ceiling.
|
|
83
|
+
*
|
|
84
|
+
* A backstop for a chain that is merely LONG. The cycle it would otherwise stand in for is detected
|
|
85
|
+
* directly — see {@link DEFAULT_MAX_AGENT_APPEARANCES}.
|
|
86
|
+
*
|
|
87
|
+
* WHEN IT ACTUALLY BINDS. At one appearance per agent a chain cannot be longer than the number of
|
|
88
|
+
* registered agents, so a deployment with fewer agents than this number never reaches it: the cycle
|
|
89
|
+
* guard always fires first. It starts mattering with a larger fleet than the ceiling, or once a host
|
|
90
|
+
* raises {@link AgentLoopDeps.maxAgentAppearances}, which is what lets a chain revisit an agent and
|
|
91
|
+
* therefore grow past the fleet's size.
|
|
92
|
+
*/
|
|
93
|
+
export const MAX_DELEGATION_DEPTH = 5;
|
|
94
|
+
/**
|
|
95
|
+
* How many times one agent may appear on a single delegation chain, when the host names no other
|
|
96
|
+
* number. Once: a chain that reaches an agent it has already passed through is going in circles.
|
|
97
|
+
*
|
|
98
|
+
* Override with {@link AgentLoopDeps.maxAgentAppearances} — a supervisor that genuinely hands work
|
|
99
|
+
* back to an earlier agent needs a larger number, and it is the count of APPEARANCES, so 2 admits
|
|
100
|
+
* exactly one return.
|
|
101
|
+
*/
|
|
102
|
+
export const DEFAULT_MAX_AGENT_APPEARANCES = 1;
|
|
103
|
+
/**
|
|
104
|
+
* Why this delegation must not happen, or `null` to let it through.
|
|
105
|
+
*
|
|
106
|
+
* Two different refusals, and the order matters: a CYCLE is named before a depth, because they
|
|
107
|
+
* describe different faults and the depth is the vaguer of the two. `alpha → beta → alpha` points at
|
|
108
|
+
* the wiring; "depth limit of 5 reached" leaves a reader to work out whether the chain was looping or
|
|
109
|
+
* merely long, which is exactly the question a count cannot answer.
|
|
110
|
+
*
|
|
111
|
+
* Decided from the run's own input and the agent's configured ceilings, inside the checkpoint that
|
|
112
|
+
* settles the call's kind — so the verdict is what a replay reads back rather than something each
|
|
113
|
+
* process re-derives.
|
|
114
|
+
*/
|
|
115
|
+
function delegationRefusal(args) {
|
|
116
|
+
const { deps, input, targetAgent } = args;
|
|
117
|
+
// `delegationPath` is the chain that REACHED this run, so this run's own agent is the last link of
|
|
118
|
+
// the chain a delegation from here would extend. Naming it is what makes the refusal describe the
|
|
119
|
+
// wiring: a mutual handoff reads `alpha → beta → alpha` rather than an `alpha → alpha` that no
|
|
120
|
+
// edge in the deployment declares. It also makes an agent delegating to ITSELF a cycle on the spot
|
|
121
|
+
// rather than one hop later.
|
|
122
|
+
const ancestry = [
|
|
123
|
+
...(input.delegationPath ?? []),
|
|
124
|
+
...(input.agentName !== undefined ? [input.agentName] : []),
|
|
125
|
+
];
|
|
126
|
+
const appearances = ancestry.filter((name) => name === targetAgent).length;
|
|
127
|
+
const maxAppearances = deps.maxAgentAppearances ?? DEFAULT_MAX_AGENT_APPEARANCES;
|
|
128
|
+
if (appearances >= maxAppearances) {
|
|
129
|
+
const chain = [...ancestry, targetAgent].join(' → ');
|
|
130
|
+
const times = appearances + 1;
|
|
131
|
+
return `delegation cycle: ${chain} — ${targetAgent} ${times} times on one chain`;
|
|
132
|
+
}
|
|
133
|
+
const maxDepth = deps.maxDelegationDepth ?? MAX_DELEGATION_DEPTH;
|
|
134
|
+
if ((input.delegationDepth ?? 0) >= maxDepth) {
|
|
135
|
+
return `delegation depth limit of ${maxDepth} reached`;
|
|
136
|
+
}
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* The gates a delegation must clear. Delegation bypasses `ToolRegistry.invoke` (it is a ctx-level
|
|
141
|
+
* suspend point), so the three checks `invoke` applies have to be re-applied here in the same order
|
|
142
|
+
* — authorization first, then shape:
|
|
143
|
+
* - the offered-tools (persona/agent allow-list) filter: a tool the model was not offered must not
|
|
144
|
+
* run, even if the model names it anyway;
|
|
145
|
+
* - the role/ability re-check. A synthesized delegate spec (`registerDelegateTools` in
|
|
146
|
+
* agent-deps-factory.ts) carries whatever `roles`/`ability` its `delegatesTo` edge declared, and
|
|
147
|
+
* nothing at all when the edge is a bare string — under `DefaultRolesPolicy` that is ADMIN-only,
|
|
148
|
+
* and under an authz posture a tool with no `ability` is always denied;
|
|
149
|
+
* - input validation, so a malformed input is rejected rather than silently coerced.
|
|
150
|
+
*
|
|
151
|
+
* Then the two nesting guards, once the target is known: {@link delegationRefusal} refuses a chain
|
|
152
|
+
* that is going in circles, or one that is merely too deep.
|
|
153
|
+
*
|
|
154
|
+
* Returns the verdict rather than throwing it: this runs inside the call's checkpoint, whose output
|
|
155
|
+
* is what a replay reads back, and it has to run BEFORE any event publication so a refusal never
|
|
156
|
+
* emits `agent.delegated`.
|
|
157
|
+
*/
|
|
158
|
+
async function resolveDelegation(deps, input, call, spec) {
|
|
159
|
+
try {
|
|
160
|
+
if (spec === undefined) {
|
|
161
|
+
// Unreachable while the kind is read off this same spec, but an `agent`-kind call with no
|
|
162
|
+
// resolvable spec must fail closed rather than fall through.
|
|
163
|
+
throw new ToolForbiddenError(call.name);
|
|
164
|
+
}
|
|
165
|
+
const offeredAllow = intersectAllow(input.persona?.allowedTools, deps.toolAllowList);
|
|
166
|
+
if (offeredAllow !== undefined && !offeredAllow.includes(call.name)) {
|
|
167
|
+
throw new ToolForbiddenError(call.name);
|
|
168
|
+
}
|
|
169
|
+
if (!(await deps.rolesPolicy.can(input.actor, spec))) {
|
|
170
|
+
throw new ToolForbiddenError(call.name);
|
|
171
|
+
}
|
|
172
|
+
const validation = await spec.inputSchema['~standard'].validate(call.input);
|
|
173
|
+
if (validation.issues !== undefined) {
|
|
174
|
+
throw new ToolInputInvalidError(call.name, validation.issues);
|
|
175
|
+
}
|
|
176
|
+
const targetAgent = spec.targetAgent ?? call.name;
|
|
177
|
+
const refusal = delegationRefusal({ deps, input, targetAgent });
|
|
178
|
+
if (refusal !== null) {
|
|
179
|
+
return { error: refusal };
|
|
180
|
+
}
|
|
181
|
+
return { targetAgent, task: extractTask(validation.value) };
|
|
182
|
+
}
|
|
183
|
+
catch (error) {
|
|
184
|
+
return { error: error instanceof Error ? error.message : String(error) };
|
|
185
|
+
}
|
|
186
|
+
}
|
|
63
187
|
function deriveTitle(userText) {
|
|
64
188
|
const trimmed = userText.trim().replace(/\s+/g, ' ');
|
|
65
189
|
return trimmed.length > 60 ? `${trimmed.slice(0, 57)}...` : trimmed || 'New chat';
|
|
@@ -75,6 +199,880 @@ function resolveCostUsd(usage, reportedCostUsd, price) {
|
|
|
75
199
|
}
|
|
76
200
|
return price === undefined ? null : estimateCost(usage, price);
|
|
77
201
|
}
|
|
202
|
+
/** Renders a folded-history summary as the leading `system` message of a windowed turn. */
|
|
203
|
+
function buildSummaryBlock(summary) {
|
|
204
|
+
return `<conversation_summary>\n${summary}\n</conversation_summary>\nEarlier messages in this thread are no longer included verbatim. Treat the summary above as an accurate record of them.`;
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Identifies the split shape to {@link AgentLoopHooks.patched}: a run whose journal holds one
|
|
208
|
+
* `history:window` checkpoint has to keep replaying against that, not against a pure selection that
|
|
209
|
+
* spends no position at all. Reached only when a window is configured, so a deployment with none
|
|
210
|
+
* never spends the marker's position and records byte-identical checkpoints.
|
|
211
|
+
*/
|
|
212
|
+
const HISTORY_SELECT_PATCH = 'agent:history-select';
|
|
213
|
+
/** The three answers, normalized out of whatever shape `load:thread` recorded them in. */
|
|
214
|
+
function threadForTurn(recorded) {
|
|
215
|
+
const messages = (recorded?.messages ?? []).map(toModelMessage);
|
|
216
|
+
return {
|
|
217
|
+
messages,
|
|
218
|
+
title: recorded?.title ?? null,
|
|
219
|
+
hasAssistantMessage: recorded?.hasAssistantMessage ?? messages.some((message) => message.role === 'assistant'),
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* A message as the model sees it — the store's own bookkeeping (`id`, `createdAt`, `usage`,
|
|
224
|
+
* `followUps`, `runId`, `persona`) left behind, so what `load:thread` records is the prompt's rows
|
|
225
|
+
* rather than the table's.
|
|
226
|
+
*/
|
|
227
|
+
function toModelMessage(message) {
|
|
228
|
+
return {
|
|
229
|
+
role: message.role,
|
|
230
|
+
content: message.content,
|
|
231
|
+
...(message.toolCalls !== undefined ? { toolCalls: message.toolCalls } : {}),
|
|
232
|
+
...(message.toolResults !== undefined ? { toolResults: message.toolResults } : {}),
|
|
233
|
+
...(message.attachments !== undefined ? { attachments: message.attachments } : {}),
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* How many of the thread's newest rows to ask for — the window's own row ceiling, or nothing at all,
|
|
238
|
+
* which asks for the transcript.
|
|
239
|
+
*
|
|
240
|
+
* A window that SUMMARIZES gets no limit. `summarize` is handed what `select` dropped, and a read
|
|
241
|
+
* bounded to what `select` keeps drops nothing: the turn would fold an empty summary into a prompt
|
|
242
|
+
* that is missing the messages it stands in for, with no error anywhere.
|
|
243
|
+
*
|
|
244
|
+
* A ceiling expressed only in TOKENS gets no limit either, and none can be derived from it: one
|
|
245
|
+
* message can be four tokens or forty thousand, so no row count follows from a token budget. Naming
|
|
246
|
+
* one too low reads fewer rows than `select` would have kept, which changes the prompt itself;
|
|
247
|
+
* leaving it out only costs the read.
|
|
248
|
+
*/
|
|
249
|
+
function turnMessageLimit(window) {
|
|
250
|
+
if (window === undefined || window.summarize !== undefined) {
|
|
251
|
+
return undefined;
|
|
252
|
+
}
|
|
253
|
+
return window.maxMessages;
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Read the thread: the store's bounded WINDOW where it offers one, else its whole transcript cut to
|
|
257
|
+
* the same bound in process.
|
|
258
|
+
*
|
|
259
|
+
* The probe is structural — the window is an optimization a store either offers or does not, and one
|
|
260
|
+
* that offers none still answers correctly. Which branch ran is invisible to the journal on purpose:
|
|
261
|
+
* both produce the same three answers over the same rows, so the payload `load:thread` records is
|
|
262
|
+
* identical either way and a deployment's choice of store can never decide a run's checkpoints. That
|
|
263
|
+
* is also why the probe lives INSIDE `load:thread` — it adds no position, and a replaying process
|
|
264
|
+
* reads the recorded payload back without calling the store at all.
|
|
265
|
+
*
|
|
266
|
+
* `hasAssistantMessage` is answered over the whole thread, never over the window. It decides a
|
|
267
|
+
* `thread-start` intake, and a window that happens to hold only the user's last questions belongs to
|
|
268
|
+
* a conversation that has still been answered — scanned off the window, such a thread re-introduces
|
|
269
|
+
* itself every turn.
|
|
270
|
+
*/
|
|
271
|
+
async function readThreadForTurn(deps, threadId, messageLimit) {
|
|
272
|
+
const windowing = deps.store;
|
|
273
|
+
if (typeof windowing.loadThreadForTurn === 'function') {
|
|
274
|
+
const page = await windowing.loadThreadForTurn({
|
|
275
|
+
threadId,
|
|
276
|
+
...(messageLimit !== undefined ? { messageLimit } : {}),
|
|
277
|
+
});
|
|
278
|
+
return page === null
|
|
279
|
+
? { messages: [], title: null, hasAssistantMessage: false }
|
|
280
|
+
: {
|
|
281
|
+
messages: page.messages.map(toModelMessage),
|
|
282
|
+
title: page.title,
|
|
283
|
+
hasAssistantMessage: page.hasAssistantMessage,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
const thread = await deps.store.getThread(threadId);
|
|
287
|
+
const stored = thread?.messages ?? [];
|
|
288
|
+
const window = messageLimit === undefined
|
|
289
|
+
? stored
|
|
290
|
+
: stored.slice(Math.max(0, stored.length - Math.max(0, messageLimit)));
|
|
291
|
+
return {
|
|
292
|
+
messages: window.map(toModelMessage),
|
|
293
|
+
title: thread?.title ?? null,
|
|
294
|
+
hasAssistantMessage: stored.some((message) => message.role === 'assistant'),
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Apply the configured ceiling to the thread's messages. `select` runs OUTSIDE any checkpoint — it
|
|
299
|
+
* is contractually pure and its input is already `load:thread`'s cached result, so a replay reaches
|
|
300
|
+
* the same split without a checkpoint of its own.
|
|
301
|
+
*
|
|
302
|
+
* Summarizing is the opposite: it calls a model. Journaled under `history:summarize`, so a resumed
|
|
303
|
+
* run reads back the summary the suspended attempt produced (and does not pay for it twice) instead
|
|
304
|
+
* of prompting with a different one. Both that checkpoint and the usage row below are reachable
|
|
305
|
+
* only through a window that actually summarizes.
|
|
306
|
+
*/
|
|
307
|
+
async function applyHistoryWindow(window, deps, input, hooks, messages, step) {
|
|
308
|
+
const ctx = { actor: input.actor, threadId: input.threadId };
|
|
309
|
+
const { keep, drop } = window.select(messages, ctx);
|
|
310
|
+
const summarize = window.summarize?.bind(window);
|
|
311
|
+
if (drop.length === 0 || summarize === undefined) {
|
|
312
|
+
return keep;
|
|
313
|
+
}
|
|
314
|
+
const summary = await step('history:summarize', () => summarize(drop, ctx));
|
|
315
|
+
if (summary.usage !== undefined) {
|
|
316
|
+
const usage = summary.usage;
|
|
317
|
+
await step('persist:usage:history', () => deps.store.recordUsage({
|
|
318
|
+
threadId: input.threadId,
|
|
319
|
+
actorRef: input.actor.id,
|
|
320
|
+
runId: hooks.runId,
|
|
321
|
+
modelId: summary.modelId ?? deps.modelId ?? 'unknown',
|
|
322
|
+
purpose: 'summary',
|
|
323
|
+
usage,
|
|
324
|
+
}));
|
|
325
|
+
}
|
|
326
|
+
return [{ role: 'system', content: buildSummaryBlock(summary.text) }, ...keep];
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* The turn's tool list plus the built-ins the module offers — `ask`, `skill`. Appended from CONFIG
|
|
330
|
+
* rather than the registry, because neither is ever registered: see {@link AgentLoopDeps.ask} and
|
|
331
|
+
* {@link AgentLoopDeps.skills}.
|
|
332
|
+
*/
|
|
333
|
+
export function withBuiltInTools(args) {
|
|
334
|
+
return [
|
|
335
|
+
...args.tools,
|
|
336
|
+
...(args.ask === true ? [askToolDefinition()] : []),
|
|
337
|
+
...(args.skills ? [skillToolDefinition()] : []),
|
|
338
|
+
...(args.memory ? [rememberToolDefinition()] : []),
|
|
339
|
+
];
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* Does this deployment offer `remember` at all? A provider that serves memory read-only omits
|
|
343
|
+
* `write`, and then the tool is never offered — so a turn's tool list is the same on every process,
|
|
344
|
+
* decided by module config rather than by what a given pod happens to hold.
|
|
345
|
+
*/
|
|
346
|
+
function memoryIsWritable(deps) {
|
|
347
|
+
return deps.memory?.provider.write !== undefined;
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* Park on a question set, through whichever wait the host implemented, and read back whatever that
|
|
351
|
+
* wait delivered as answers.
|
|
352
|
+
*
|
|
353
|
+
* The reduction of a yes/no channel — approve is "confirmed the pre-picked answers", reject is
|
|
354
|
+
* "skipped" — applies to BOTH branches, because the shape of the reply is decided by whoever settled
|
|
355
|
+
* the tool call, not by which hook the host wired. The question set sits in the approvals inbox as a
|
|
356
|
+
* `pending_approval` action, so an operator pressing Approve there sends a `Decision` into a run
|
|
357
|
+
* whose host implements `awaitAnswers` perfectly well.
|
|
358
|
+
*
|
|
359
|
+
* Neither branch moves a checkpoint: the wait sits at the same position either way, so the choice of
|
|
360
|
+
* hook cannot change the sequence a replay lines up with.
|
|
361
|
+
*/
|
|
362
|
+
async function awaitElicitation(args) {
|
|
363
|
+
const { hooks, request, ctx } = args;
|
|
364
|
+
if (hooks.awaitAnswers !== undefined) {
|
|
365
|
+
return normalizeElicitationReply(await hooks.awaitAnswers(request, ctx));
|
|
366
|
+
}
|
|
367
|
+
return normalizeElicitationReply(await hooks.awaitApproval({ id: request.id, name: ASK_TOOL_NAME, input: request }, ctx));
|
|
368
|
+
}
|
|
369
|
+
/** How many wrong-shaped replies one approval wait absorbs before {@link awaitDecision} gives up. */
|
|
370
|
+
const MAX_DISCARDED_APPROVAL_REPLIES = 100;
|
|
371
|
+
/**
|
|
372
|
+
* Park on an `action`'s approval until a human's yes/no actually arrives.
|
|
373
|
+
*
|
|
374
|
+
* The wait carries both reply shapes, because a question set parks as a `pending_approval` action
|
|
375
|
+
* too — so a client can address `POST /agent/tool-call/answer` at a call that is in fact waiting on
|
|
376
|
+
* an approve/reject. A set of answers is not a verdict on proposed work; reading its absent
|
|
377
|
+
* `approved` as `false` would persist a rejection nobody made, against a tool whose operator only
|
|
378
|
+
* ever submitted a form. The reply is discarded and the approval stays pending, which is what it is.
|
|
379
|
+
*
|
|
380
|
+
* Waiting again spends another position, and a replay lines up with it: the reply that was discarded
|
|
381
|
+
* is itself journaled at the position it arrived on, so every process replaying the turn discards
|
|
382
|
+
* the same replies in the same order and reaches the same wait.
|
|
383
|
+
*/
|
|
384
|
+
async function awaitDecision(args) {
|
|
385
|
+
const { hooks, call, ctx } = args;
|
|
386
|
+
for (let discarded = 0; discarded < MAX_DISCARDED_APPROVAL_REPLIES; discarded += 1) {
|
|
387
|
+
const reply = await hooks.awaitApproval(call, ctx);
|
|
388
|
+
if (isHumanDecision(reply)) {
|
|
389
|
+
return reply;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
// Both shipped runners wait here, so the ceiling is out of reach: a person would have to misdirect
|
|
393
|
+
// the whole budget at one tool call. What it rules out is a HOST whose `awaitApproval` resolves
|
|
394
|
+
// WITHOUT waiting — which would otherwise spin this loop on the only thread the process has.
|
|
395
|
+
throw new Error(`Tool call "${call.id}" was sent ${MAX_DISCARDED_APPROVAL_REPLIES} replies that carried no approve/reject`);
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* Does the configured intake run this turn? Both inputs are facts the journal already holds — the
|
|
399
|
+
* config, and `load:thread`'s record of whether the thread had an assistant message when the turn
|
|
400
|
+
* began. Neither is re-read from the store here, and that is the point: by the time a replay reaches
|
|
401
|
+
* this line the first attempt has already appended the intake's own assistant message to the thread,
|
|
402
|
+
* so a fresh read would answer differently on the resume than it did on the way in — and the answer
|
|
403
|
+
* decides how many checkpoints follow it.
|
|
404
|
+
*/
|
|
405
|
+
function intakeApplies(args) {
|
|
406
|
+
return args.intake.when === 'every-turn' || !args.threadHasAssistant;
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* The configured intake: post the authored question set, park until a human settles it, and leave
|
|
410
|
+
* the exchange on the transcript as an ordinary tool round-trip so the model reads the answers the
|
|
411
|
+
* same way it reads its own `ask`'s.
|
|
412
|
+
*
|
|
413
|
+
* COSTS NO MODEL CALL. The questions, their options and their pre-picked defaults are all authored
|
|
414
|
+
* on the agent, so nothing here is generated and nothing here is billed — which is also why the
|
|
415
|
+
* total is knowable before the first question is shown.
|
|
416
|
+
*
|
|
417
|
+
* One checkpoint whenever an `intake` is configured, plus two more on the turns it actually asks —
|
|
418
|
+
* all reachable only through that config, so a run that predates the option cannot land on any of
|
|
419
|
+
* them and no {@link AgentLoopHooks.patched} marker is spent keeping the sequence stable for it.
|
|
420
|
+
*/
|
|
421
|
+
async function runIntake(args) {
|
|
422
|
+
const { intake, deps, input, hooks, writer, threadHasAssistant } = args;
|
|
423
|
+
const preamble = intake.preamble ?? DEFAULT_INTAKE_PREAMBLE;
|
|
424
|
+
// Derived from the run, so a replay rebuilds the same id without minting one — the id is a
|
|
425
|
+
// checkpoint name's suffix and the signal's own key, so it cannot come from a random source.
|
|
426
|
+
const request = {
|
|
427
|
+
id: `intake-${hooks.runId}`,
|
|
428
|
+
source: 'intake',
|
|
429
|
+
preamble,
|
|
430
|
+
questions: intake.questions,
|
|
431
|
+
};
|
|
432
|
+
const call = {
|
|
433
|
+
id: request.id,
|
|
434
|
+
name: ASK_TOOL_NAME,
|
|
435
|
+
input: { preamble, questions: intake.questions },
|
|
436
|
+
};
|
|
437
|
+
const asked = (await hooks.step('intake:ask', async () => {
|
|
438
|
+
if (!intakeApplies({ intake, threadHasAssistant })) {
|
|
439
|
+
return null;
|
|
440
|
+
}
|
|
441
|
+
const message = await deps.store.appendMessage({
|
|
442
|
+
threadId: input.threadId,
|
|
443
|
+
role: 'assistant',
|
|
444
|
+
content: preamble,
|
|
445
|
+
runId: hooks.runId,
|
|
446
|
+
toolCalls: [call],
|
|
447
|
+
...(input.persona !== undefined ? { persona: input.persona.id } : {}),
|
|
448
|
+
});
|
|
449
|
+
await deps.store.recordToolCall({
|
|
450
|
+
toolCallId: request.id,
|
|
451
|
+
messageId: message.id,
|
|
452
|
+
toolName: ASK_TOOL_NAME,
|
|
453
|
+
// The store knows read/action only. An unanswered question is work waiting on a human, which
|
|
454
|
+
// is what `action` + `pending_approval` already mean — so it surfaces in an approvals inbox
|
|
455
|
+
// rather than needing one of its own.
|
|
456
|
+
toolType: 'action',
|
|
457
|
+
input: call.input,
|
|
458
|
+
status: 'pending_approval',
|
|
459
|
+
runId: hooks.runId,
|
|
460
|
+
});
|
|
461
|
+
await writer.write({ t: 'elicitation', runId: hooks.runId, id: request.id, request });
|
|
462
|
+
return message.id;
|
|
463
|
+
}));
|
|
464
|
+
if (asked === null || asked === false) {
|
|
465
|
+
return null;
|
|
466
|
+
}
|
|
467
|
+
// A checkpoint that recorded only WHETHER the intake ran carries no message id, so such a run
|
|
468
|
+
// settles the tool-call row alone.
|
|
469
|
+
const messageId = typeof asked === 'string' ? asked : null;
|
|
470
|
+
const reply = await awaitElicitation({
|
|
471
|
+
hooks,
|
|
472
|
+
request,
|
|
473
|
+
ctx: elicitationContext(deps, input, hooks),
|
|
474
|
+
});
|
|
475
|
+
const result = settleElicitation({ request, reply });
|
|
476
|
+
await hooks.step('intake:answers', async () => {
|
|
477
|
+
await deps.store.updateToolCall({
|
|
478
|
+
toolCallId: request.id,
|
|
479
|
+
// A skip is not an answer. Both leave the agent holding the same values, but only one of them
|
|
480
|
+
// is evidence the user chose them, and a reader auditing what the agent was told has to be
|
|
481
|
+
// able to tell those apart.
|
|
482
|
+
status: result.skipped ? 'rejected' : 'executed',
|
|
483
|
+
output: result,
|
|
484
|
+
...(result.skipped ? { error: 'skipped by the user' } : {}),
|
|
485
|
+
...(reply.answeredByRef !== undefined ? { executedByRef: reply.answeredByRef } : {}),
|
|
486
|
+
});
|
|
487
|
+
if (messageId !== null) {
|
|
488
|
+
await deps.store.setMessageToolResults(messageId, [
|
|
489
|
+
{ id: request.id, name: ASK_TOOL_NAME, output: result },
|
|
490
|
+
]);
|
|
491
|
+
}
|
|
492
|
+
});
|
|
493
|
+
return {
|
|
494
|
+
role: 'assistant',
|
|
495
|
+
content: preamble,
|
|
496
|
+
toolCalls: [call],
|
|
497
|
+
toolResults: [{ id: request.id, name: ASK_TOOL_NAME, output: result }],
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
/** The context a wait is handed. The intake has no tool ctx of its own — it is not a tool call. */
|
|
501
|
+
function elicitationContext(deps, input, hooks) {
|
|
502
|
+
return {
|
|
503
|
+
actor: input.actor,
|
|
504
|
+
threadId: input.threadId,
|
|
505
|
+
runId: hooks.runId,
|
|
506
|
+
requestId: hooks.runId,
|
|
507
|
+
...(input.persona !== undefined ? { persona: input.persona } : {}),
|
|
508
|
+
...(input.pageContext !== undefined ? { pageContext: input.pageContext } : {}),
|
|
509
|
+
...(deps.host !== undefined ? { host: deps.host } : {}),
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
/**
|
|
513
|
+
* Identifies the batched tool-call shape to {@link AgentLoopHooks.patched}: batching hoists a turn's
|
|
514
|
+
* `persist:toolcall` checkpoints ahead of its first tool execution, so a run whose journal
|
|
515
|
+
* interleaves them one call at a time has to keep replaying against THAT.
|
|
516
|
+
*/
|
|
517
|
+
const PARALLEL_TOOLS_PATCH = 'agent:parallel-tools';
|
|
518
|
+
/**
|
|
519
|
+
* Identifies the message-borne tool results to {@link AgentLoopHooks.patched}: attaching them adds a
|
|
520
|
+
* checkpoint after a turn's last tool, a position a run recorded under the shape without it cannot
|
|
521
|
+
* supply.
|
|
522
|
+
*/
|
|
523
|
+
const MESSAGE_TOOL_RESULTS_PATCH = 'agent:message-tool-results';
|
|
524
|
+
/**
|
|
525
|
+
* Record the call and settle its KIND, which decides this call's control flow: an `action` suspends
|
|
526
|
+
* the run on an approval signal (`tool:<runId>:<callId>`), an `agent` delegates to a child run,
|
|
527
|
+
* anything else records a plain step. Resolving it from `deps.registry` in the loop body would tie
|
|
528
|
+
* that branch to the registry of WHICHEVER PROCESS runs the body — and a process whose registry
|
|
529
|
+
* lacks the tool reads `undefined`, falls back to 'read', and then asks for a `tool:` checkpoint
|
|
530
|
+
* where the history holds the approval signal. That is a non-determinism refusal on resume, and it
|
|
531
|
+
* is an approval-gated action about to run with nobody's approval.
|
|
532
|
+
*
|
|
533
|
+
* So the lookup — and the delegation gates that hang off it — happen INSIDE the `persist:toolcall`
|
|
534
|
+
* step, and the verdict is RETURNED from it. The first process to reach this call writes the kind
|
|
535
|
+
* into the journal; every later replay reads it back instead of asking its own registry.
|
|
536
|
+
*/
|
|
537
|
+
async function claimToolCall(turn, call) {
|
|
538
|
+
const { deps, input, hooks, messageId, writer } = turn;
|
|
539
|
+
const persona = input.persona;
|
|
540
|
+
const persisted = (await hooks.step(`persist:toolcall:${call.id}`, async () => {
|
|
541
|
+
const kind = declaredKind(deps, call.name);
|
|
542
|
+
if (kind === 'agent') {
|
|
543
|
+
const delegation = await resolveDelegation(deps, input, call, deps.registry.spec(call.name));
|
|
544
|
+
await deps.store.recordToolCall({
|
|
545
|
+
toolCallId: call.id,
|
|
546
|
+
messageId,
|
|
547
|
+
toolName: call.name,
|
|
548
|
+
// The store knows read/action only; a delegation is neither approved nor rejected by a
|
|
549
|
+
// human, so it persists as a read. A refused one is recorded `failed` directly — never
|
|
550
|
+
// `auto_executed` even transiently.
|
|
551
|
+
toolType: 'read',
|
|
552
|
+
input: call.input,
|
|
553
|
+
status: delegation.error !== undefined ? 'failed' : 'auto_executed',
|
|
554
|
+
runId: hooks.runId,
|
|
555
|
+
});
|
|
556
|
+
return { kind, delegation };
|
|
557
|
+
}
|
|
558
|
+
// An `ask` persists exactly as an `action` does: the store knows read/action only, and an
|
|
559
|
+
// unanswered question is work waiting on a human — which is what `pending_approval` already
|
|
560
|
+
// means, so it lands in the approvals inbox a deployment already has.
|
|
561
|
+
const parks = kind === 'action' || kind === 'ask';
|
|
562
|
+
await deps.store.recordToolCall({
|
|
563
|
+
toolCallId: call.id,
|
|
564
|
+
messageId,
|
|
565
|
+
toolName: call.name,
|
|
566
|
+
toolType: parks ? 'action' : 'read',
|
|
567
|
+
input: call.input,
|
|
568
|
+
status: parks ? 'pending_approval' : 'auto_executed',
|
|
569
|
+
runId: hooks.runId,
|
|
570
|
+
});
|
|
571
|
+
// Written from INSIDE this checkpoint, so the frame is streamed once and a replay — which
|
|
572
|
+
// returns the memoized result without re-running the body — never re-posts a form for a
|
|
573
|
+
// decision already made. That is also why it spends no position of its own.
|
|
574
|
+
//
|
|
575
|
+
// An `ask` is left out: `elicitToolCall` posts its `elicitation` frame, which carries the
|
|
576
|
+
// whole question set. Two frames for one parked call would be two forms.
|
|
577
|
+
if (kind === 'action') {
|
|
578
|
+
await writer.write({
|
|
579
|
+
t: 'approval',
|
|
580
|
+
runId: hooks.runId,
|
|
581
|
+
id: call.id,
|
|
582
|
+
toolName: call.name,
|
|
583
|
+
input: call.input,
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
return { kind };
|
|
587
|
+
}));
|
|
588
|
+
// A checkpoint written before the kind was journaled carries no output. Such a run is already
|
|
589
|
+
// committed to whatever its first process resolved, so the local registry is the only thing left
|
|
590
|
+
// to consult — reached by those runs alone.
|
|
591
|
+
const toolType = persisted?.kind ?? declaredKind(deps, call.name);
|
|
592
|
+
return {
|
|
593
|
+
call,
|
|
594
|
+
toolType,
|
|
595
|
+
...(persisted?.delegation !== undefined ? { delegation: persisted.delegation } : {}),
|
|
596
|
+
ctx: {
|
|
597
|
+
actor: input.actor,
|
|
598
|
+
threadId: input.threadId,
|
|
599
|
+
runId: hooks.runId,
|
|
600
|
+
requestId: hooks.runId,
|
|
601
|
+
emitComponent: (name, data) => writer.write({ t: 'component', name, data }),
|
|
602
|
+
...(persona !== undefined ? { persona } : {}),
|
|
603
|
+
...(input.pageContext !== undefined ? { pageContext: input.pageContext } : {}),
|
|
604
|
+
...(deps.host !== undefined ? { host: deps.host } : {}),
|
|
605
|
+
},
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
/**
|
|
609
|
+
* Execute one claimed call. Asks for its checkpoint SYNCHRONOUSLY — nothing is awaited before
|
|
610
|
+
* `hooks.step` — which is what lets a batch of these be launched together and still occupy
|
|
611
|
+
* positions in call order (see {@link AgentLoopHooks.parallel}).
|
|
612
|
+
*
|
|
613
|
+
* A tool's own failure is an OUTCOME, not a throw: the model is handed it as a result and adapts.
|
|
614
|
+
* Only the runner's control flow (a suspend / continue-as-new) and a replay-integrity refusal
|
|
615
|
+
* escape — answering either of those with a `persist:toolfail` checkpoint would ask for a position
|
|
616
|
+
* a diverged history has no room for, so the operator reads that second refusal instead of the
|
|
617
|
+
* disagreement that caused it.
|
|
618
|
+
*
|
|
619
|
+
* The retry loop runs INSIDE the `tool:<call.id>` step so it stays replay-safe: a durable step
|
|
620
|
+
* memoizes only its successful result, so on replay the whole step returns cached and the retries
|
|
621
|
+
* never re-run (side effects happen exactly once). The span sits inside the step body for the same
|
|
622
|
+
* reason, and covers all in-place attempts; the tool's output never rides it.
|
|
623
|
+
*/
|
|
624
|
+
/**
|
|
625
|
+
* The span's narrower tool vocabulary. An `ask` is settled against a human and a `skill` is read out
|
|
626
|
+
* of the journal, so neither reaches a registry invocation; each maps to the kind whose control flow
|
|
627
|
+
* it shares, for a reader of a trace who only ever sees the three.
|
|
628
|
+
*/
|
|
629
|
+
function spanToolType(kind) {
|
|
630
|
+
if (kind === 'ask') {
|
|
631
|
+
return 'action';
|
|
632
|
+
}
|
|
633
|
+
return kind === 'skill' || kind === 'memory' ? 'read' : kind;
|
|
634
|
+
}
|
|
635
|
+
async function invokeClaimedTool(turn, claimed) {
|
|
636
|
+
const { deps, hooks } = turn;
|
|
637
|
+
const { call, ctx, toolType } = claimed;
|
|
638
|
+
try {
|
|
639
|
+
const output = await hooks.step(`tool:${call.id}`, () => spannedAgent('tool.execution', hooks.runId, {
|
|
640
|
+
runId: hooks.runId,
|
|
641
|
+
toolCallId: call.id,
|
|
642
|
+
toolName: call.name,
|
|
643
|
+
toolType: spanToolType(toolType),
|
|
644
|
+
}, () => invokeWithTransientRetry(() => deps.registry.invoke(call.name, call.input, ctx, deps.rolesPolicy), deps.toolTransientRetry ?? {}, {
|
|
645
|
+
...(hooks.isControlFlowError !== undefined
|
|
646
|
+
? { isControlFlowError: hooks.isControlFlowError }
|
|
647
|
+
: {}),
|
|
648
|
+
onRetry: (attempt, retryError) => {
|
|
649
|
+
publishAgentToolRetry({
|
|
650
|
+
runId: hooks.runId,
|
|
651
|
+
toolName: call.name,
|
|
652
|
+
toolCallId: call.id,
|
|
653
|
+
attempt,
|
|
654
|
+
message: retryError instanceof Error ? retryError.message : String(retryError),
|
|
655
|
+
});
|
|
656
|
+
},
|
|
657
|
+
}), () => ({})));
|
|
658
|
+
return { status: 'executed', output };
|
|
659
|
+
}
|
|
660
|
+
catch (error) {
|
|
661
|
+
if (isReplayIntegrityError(error) || hooks.isControlFlowError?.(error) === true) {
|
|
662
|
+
throw error;
|
|
663
|
+
}
|
|
664
|
+
return { status: 'failed', error: error instanceof Error ? error.message : String(error) };
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
/** Persist one settled invocation and shape the result the model is fed. */
|
|
668
|
+
async function recordToolOutcome(turn, claimed, outcome) {
|
|
669
|
+
const { deps, input, hooks } = turn;
|
|
670
|
+
const { call } = claimed;
|
|
671
|
+
// An `agent` call branches to delegation before reaching here, so anything that isn't an `action`
|
|
672
|
+
// is a read — the same posture the kind fallback itself takes.
|
|
673
|
+
const toolType = claimed.toolType === 'action' ? 'action' : 'read';
|
|
674
|
+
if (outcome.status === 'failed') {
|
|
675
|
+
await hooks.step(`persist:toolfail:${call.id}`, () => deps.store.updateToolCall({ toolCallId: call.id, status: 'failed', error: outcome.error }));
|
|
676
|
+
publishAgentToolCall({ runId: hooks.runId, toolName: call.name, toolType, status: 'failed' });
|
|
677
|
+
return { id: call.id, name: call.name, output: null, error: outcome.error };
|
|
678
|
+
}
|
|
679
|
+
await hooks.step(`persist:toolexec:${call.id}`, () => deps.store.updateToolCall({
|
|
680
|
+
toolCallId: call.id,
|
|
681
|
+
status: 'executed',
|
|
682
|
+
output: outcome.output,
|
|
683
|
+
...(toolType === 'action' ? { executedByRef: input.actor.id } : {}),
|
|
684
|
+
}));
|
|
685
|
+
publishAgentToolCall({ runId: hooks.runId, toolName: call.name, toolType, status: 'executed' });
|
|
686
|
+
return { id: call.id, name: call.name, output: outcome.output };
|
|
687
|
+
}
|
|
688
|
+
/**
|
|
689
|
+
* Delegate to another agent (an `agent`-kind call). Dispatched at the LOOP level (not in a step)
|
|
690
|
+
* because the durable runner maps it to `ctx.child`, a ctx-level suspend point.
|
|
691
|
+
*/
|
|
692
|
+
async function delegateToolCall(turn, claimed) {
|
|
693
|
+
const { deps, input, hooks } = turn;
|
|
694
|
+
const { call } = claimed;
|
|
695
|
+
const delegation = claimed.delegation ??
|
|
696
|
+
(await resolveDelegation(deps, input, call, deps.registry.spec(call.name)));
|
|
697
|
+
if (delegation.error !== undefined) {
|
|
698
|
+
return { id: call.id, name: call.name, output: null, error: delegation.error };
|
|
699
|
+
}
|
|
700
|
+
const { targetAgent, task } = delegation;
|
|
701
|
+
publishAgentDelegated({
|
|
702
|
+
runId: hooks.runId,
|
|
703
|
+
toAgent: targetAgent,
|
|
704
|
+
...(input.agentName !== undefined ? { fromAgent: input.agentName } : {}),
|
|
705
|
+
});
|
|
706
|
+
const sub = hooks.runAgent
|
|
707
|
+
? await hooks.runAgent(targetAgent, task)
|
|
708
|
+
: { text: `(no multi-agent support wired; cannot reach "${targetAgent}")` };
|
|
709
|
+
await hooks.step(`persist:toolexec:${call.id}`, () => deps.store.updateToolCall({ toolCallId: call.id, status: 'executed', output: sub }));
|
|
710
|
+
return { id: call.id, name: call.name, output: sub };
|
|
711
|
+
}
|
|
712
|
+
/**
|
|
713
|
+
* A call's declared kind, as settled inside `persist:toolcall` and journaled from there.
|
|
714
|
+
*
|
|
715
|
+
* The reserved `ask` name resolves from module CONFIG, never from the registry: `ask` has no handler
|
|
716
|
+
* to register, and grounding its branch in config is what keeps a process with a partial registry
|
|
717
|
+
* from disagreeing about it — the same property the registry lookup below has only because its
|
|
718
|
+
* answer is written into the journal. Every other name is the registry's `ToolSpec`, as before.
|
|
719
|
+
*/
|
|
720
|
+
function declaredKind(deps, name) {
|
|
721
|
+
if (deps.ask === true && name === ASK_TOOL_NAME) {
|
|
722
|
+
return 'ask';
|
|
723
|
+
}
|
|
724
|
+
if (deps.skills !== undefined && name === SKILL_TOOL_NAME) {
|
|
725
|
+
return 'skill';
|
|
726
|
+
}
|
|
727
|
+
if (memoryIsWritable(deps) && name === REMEMBER_TOOL_NAME) {
|
|
728
|
+
return 'memory';
|
|
729
|
+
}
|
|
730
|
+
return deps.registry.spec(name)?.kind ?? 'read';
|
|
731
|
+
}
|
|
732
|
+
/**
|
|
733
|
+
* Settle an `ask` call against a human. The model authored the questions, so unlike the configured
|
|
734
|
+
* intake there is nothing to persist first — `persist:toolcall` already wrote the row, under the same
|
|
735
|
+
* name and at the same position any other kind would have used.
|
|
736
|
+
*
|
|
737
|
+
* Costs nothing beyond the step that produced it: the question set arrived as the ARGUMENTS of a tool
|
|
738
|
+
* call the model was already making, in a step already billed as `chat`. There is no second model
|
|
739
|
+
* call and therefore no usage row of its own — the opposite of the structured-output pass.
|
|
740
|
+
*
|
|
741
|
+
* The two new positions here (`stream:elicitation:<id>`, and the wait) are reachable only when this
|
|
742
|
+
* call's journaled kind is `ask`, which no run recorded before the kind existed. Every replay reads
|
|
743
|
+
* that kind back rather than re-deciding it, so no run can disagree about whether they are there.
|
|
744
|
+
*/
|
|
745
|
+
async function elicitToolCall(turn, claimed) {
|
|
746
|
+
const { deps, hooks } = turn;
|
|
747
|
+
const { call, ctx } = claimed;
|
|
748
|
+
// Validated HERE rather than by the registry, which never sees this tool. Deterministic on the
|
|
749
|
+
// same footing as `validateStructured`: a module-constant schema over an input the `llm:<i>`
|
|
750
|
+
// checkpoint already holds, so every replay reaches the same verdict — and the same branch.
|
|
751
|
+
const parsed = await askInputSchema['~standard'].validate(call.input);
|
|
752
|
+
if (parsed.issues !== undefined) {
|
|
753
|
+
const error = `invalid ask input: ${parsed.issues
|
|
754
|
+
.map((each) => `${(each.path ?? []).join('.') || '(root)'}: ${each.message}`)
|
|
755
|
+
.join('; ')}`;
|
|
756
|
+
await hooks.step(`persist:toolfail:${call.id}`, () => deps.store.updateToolCall({ toolCallId: call.id, status: 'failed', error }));
|
|
757
|
+
// A malformed question set is the model's mistake to fix, so it comes back as a tool failure —
|
|
758
|
+
// the vocabulary the model already knows how to answer — rather than failing the run.
|
|
759
|
+
return { id: call.id, name: call.name, output: null, error };
|
|
760
|
+
}
|
|
761
|
+
const request = {
|
|
762
|
+
id: call.id,
|
|
763
|
+
source: 'ask',
|
|
764
|
+
questions: parsed.value.questions,
|
|
765
|
+
...(parsed.value.preamble !== undefined ? { preamble: parsed.value.preamble } : {}),
|
|
766
|
+
};
|
|
767
|
+
await hooks.step(`stream:elicitation:${call.id}`, () => Promise.resolve(turn.writer.write({ t: 'elicitation', runId: hooks.runId, id: call.id, request })));
|
|
768
|
+
const reply = await awaitElicitation({ hooks, request, ctx });
|
|
769
|
+
const result = settleElicitation({ request, reply });
|
|
770
|
+
await hooks.step(result.skipped ? `persist:toolreject:${call.id}` : `persist:toolexec:${call.id}`, () => deps.store.updateToolCall({
|
|
771
|
+
toolCallId: call.id,
|
|
772
|
+
status: result.skipped ? 'rejected' : 'executed',
|
|
773
|
+
output: result,
|
|
774
|
+
...(result.skipped ? { error: 'skipped by the user' } : {}),
|
|
775
|
+
...(reply.answeredByRef !== undefined ? { executedByRef: reply.answeredByRef } : {}),
|
|
776
|
+
}));
|
|
777
|
+
publishAgentToolCall({
|
|
778
|
+
runId: hooks.runId,
|
|
779
|
+
toolName: call.name,
|
|
780
|
+
toolType: 'action',
|
|
781
|
+
status: result.skipped ? 'rejected' : 'executed',
|
|
782
|
+
});
|
|
783
|
+
return { id: call.id, name: call.name, output: result };
|
|
784
|
+
}
|
|
785
|
+
/** The turn's identity as the skills seam sees it — the same inputs the prompt is resolved from. */
|
|
786
|
+
function skillContext(input) {
|
|
787
|
+
return {
|
|
788
|
+
actor: input.actor,
|
|
789
|
+
threadId: input.threadId,
|
|
790
|
+
...(input.agentName !== undefined ? { agentName: input.agentName } : {}),
|
|
791
|
+
...(input.pageContext !== undefined ? { pageContext: input.pageContext } : {}),
|
|
792
|
+
};
|
|
793
|
+
}
|
|
794
|
+
/**
|
|
795
|
+
* Serve one `skill` call: read the body and hand it to the model as an ordinary tool result.
|
|
796
|
+
*
|
|
797
|
+
* WHY THE BODY IS RETURNED FROM THE CHECKPOINT. Which instructions entered a turn's prompt is a
|
|
798
|
+
* decision about the turn, and a decision about a turn has to be readable from its journal — the
|
|
799
|
+
* same property `persist:toolcall` gives the read/action branch. A replay that re-read the provider
|
|
800
|
+
* would compose a DIFFERENT prompt from a skill edited in between, at a transcript position the
|
|
801
|
+
* history already holds: the model would then be answering something nobody can reconstruct from the
|
|
802
|
+
* record. Inside `tool:<callId>` the first attempt's text is the only text there ever was.
|
|
803
|
+
*
|
|
804
|
+
* The positions are a read tool's, exactly (`persist:toolcall` before, `tool:<id>` here,
|
|
805
|
+
* `persist:toolexec:<id>` / `persist:toolfail:<id>` after), so nothing about the shape of a turn
|
|
806
|
+
* depends on whether a call was a skill — only on the kind the journal recorded.
|
|
807
|
+
*/
|
|
808
|
+
async function loadSkillIntoTurn(turn, claimed) {
|
|
809
|
+
const { deps, input, hooks } = turn;
|
|
810
|
+
const { call } = claimed;
|
|
811
|
+
const config = deps.skills;
|
|
812
|
+
const offer = turn.skills;
|
|
813
|
+
const outcome = await hooks.step(`tool:${call.id}`, async () => {
|
|
814
|
+
if (config === undefined || offer === undefined) {
|
|
815
|
+
// Reachable where a call's journaled kind is `skill` but this process has no skills
|
|
816
|
+
// configured. A tool failure, because it is the one vocabulary the model can act on, and
|
|
817
|
+
// because failing the run would strand a turn over a lookup.
|
|
818
|
+
return { ok: false, error: 'Skills are not available in this deployment.' };
|
|
819
|
+
}
|
|
820
|
+
const parsed = await skillInputSchema['~standard'].validate(call.input);
|
|
821
|
+
if (parsed.issues !== undefined) {
|
|
822
|
+
return {
|
|
823
|
+
ok: false,
|
|
824
|
+
error: `invalid skill input: ${parsed.issues
|
|
825
|
+
.map((each) => `${(each.path ?? []).join('.') || '(root)'}: ${each.message}`)
|
|
826
|
+
.join('; ')}`,
|
|
827
|
+
};
|
|
828
|
+
}
|
|
829
|
+
const loaded = await loadSkill({
|
|
830
|
+
config,
|
|
831
|
+
offer,
|
|
832
|
+
name: parsed.value.name,
|
|
833
|
+
ctx: skillContext(input),
|
|
834
|
+
});
|
|
835
|
+
if (!loaded.ok) {
|
|
836
|
+
return { ok: false, error: loaded.error };
|
|
837
|
+
}
|
|
838
|
+
return {
|
|
839
|
+
ok: true,
|
|
840
|
+
output: {
|
|
841
|
+
name: loaded.skill.name,
|
|
842
|
+
scope: loaded.skill.scope,
|
|
843
|
+
body: loaded.skill.body,
|
|
844
|
+
...(loaded.shadows !== undefined ? { shadows: loaded.shadows } : {}),
|
|
845
|
+
},
|
|
846
|
+
};
|
|
847
|
+
});
|
|
848
|
+
return outcome.ok
|
|
849
|
+
? { status: 'executed', output: outcome.output }
|
|
850
|
+
: { status: 'failed', error: outcome.error };
|
|
851
|
+
}
|
|
852
|
+
/**
|
|
853
|
+
* Serve one `remember` call: write the fact and hand the stored record back as an ordinary tool
|
|
854
|
+
* result.
|
|
855
|
+
*
|
|
856
|
+
* WHY THE WRITE HAPPENS INSIDE THE CHECKPOINT. Two reasons, and the second is the one that is easy
|
|
857
|
+
* to miss. It makes the write idempotent under replay — a resumed run reads the record back from
|
|
858
|
+
* `tool:<callId>` instead of storing a second copy of a fact the model only decided once. And it
|
|
859
|
+
* makes what the model was TOLD about the write part of the journal, so the transcript a later step
|
|
860
|
+
* reads is the one the first attempt built rather than whatever a second write would have returned.
|
|
861
|
+
*
|
|
862
|
+
* The positions are a read tool's, exactly, so nothing about the shape of a turn depends on whether
|
|
863
|
+
* a call wrote a memory — only on the kind the journal recorded.
|
|
864
|
+
*/
|
|
865
|
+
async function rememberIntoTurn(turn, claimed) {
|
|
866
|
+
const { deps, input, hooks } = turn;
|
|
867
|
+
const { call } = claimed;
|
|
868
|
+
const config = deps.memory;
|
|
869
|
+
const digest = turn.memory;
|
|
870
|
+
const outcome = await hooks.step(`tool:${call.id}`, async () => {
|
|
871
|
+
if (config === undefined || digest === undefined) {
|
|
872
|
+
// Reachable where a call's journaled kind is `memory` but this process has none configured.
|
|
873
|
+
// A tool failure, because it is the one vocabulary the model can act on, and because failing
|
|
874
|
+
// the run would strand a turn over a lookup.
|
|
875
|
+
return { ok: false, error: 'Memory is not available in this deployment.' };
|
|
876
|
+
}
|
|
877
|
+
const parsed = await rememberInputSchema['~standard'].validate(call.input);
|
|
878
|
+
if (parsed.issues !== undefined) {
|
|
879
|
+
return {
|
|
880
|
+
ok: false,
|
|
881
|
+
error: `invalid remember input: ${parsed.issues
|
|
882
|
+
.map((each) => `${(each.path ?? []).join('.') || '(root)'}: ${each.message}`)
|
|
883
|
+
.join('; ')}`,
|
|
884
|
+
};
|
|
885
|
+
}
|
|
886
|
+
return writeMemory({
|
|
887
|
+
config,
|
|
888
|
+
digest,
|
|
889
|
+
call: parsed.value,
|
|
890
|
+
ctx: skillContext(input),
|
|
891
|
+
runId: hooks.runId,
|
|
892
|
+
});
|
|
893
|
+
});
|
|
894
|
+
return outcome.ok
|
|
895
|
+
? { status: 'executed', output: outcome.record }
|
|
896
|
+
: { status: 'failed', error: outcome.error };
|
|
897
|
+
}
|
|
898
|
+
/**
|
|
899
|
+
* Run one claimed call's INVOCATION, whatever kind it is. A `skill` reads the catalog the journal
|
|
900
|
+
* holds instead of the registry; everything that reaches here spends the same `tool:<id>` position
|
|
901
|
+
* either way, which is what lets the two be batched together.
|
|
902
|
+
*/
|
|
903
|
+
function invokeClaimed(turn, claimed) {
|
|
904
|
+
if (claimed.toolType === 'skill') {
|
|
905
|
+
return loadSkillIntoTurn(turn, claimed);
|
|
906
|
+
}
|
|
907
|
+
return claimed.toolType === 'memory'
|
|
908
|
+
? rememberIntoTurn(turn, claimed)
|
|
909
|
+
: invokeClaimedTool(turn, claimed);
|
|
910
|
+
}
|
|
911
|
+
/** Everything a claimed call still needs, on its own: delegation or approval, then execute. */
|
|
912
|
+
async function runClaimedToolCall(turn, claimed) {
|
|
913
|
+
const { deps, hooks } = turn;
|
|
914
|
+
const { call, toolType, ctx } = claimed;
|
|
915
|
+
if (toolType === 'agent') {
|
|
916
|
+
return delegateToolCall(turn, claimed);
|
|
917
|
+
}
|
|
918
|
+
if (toolType === 'ask') {
|
|
919
|
+
return elicitToolCall(turn, claimed);
|
|
920
|
+
}
|
|
921
|
+
if (toolType === 'action') {
|
|
922
|
+
const decision = await awaitDecision({ hooks, call, ctx });
|
|
923
|
+
if (!decision.approved) {
|
|
924
|
+
await hooks.step(`persist:toolreject:${call.id}`, () => deps.store.updateToolCall({
|
|
925
|
+
toolCallId: call.id,
|
|
926
|
+
status: 'rejected',
|
|
927
|
+
...(decision.reason !== undefined ? { error: decision.reason } : {}),
|
|
928
|
+
}));
|
|
929
|
+
publishAgentToolCall({
|
|
930
|
+
runId: hooks.runId,
|
|
931
|
+
toolName: call.name,
|
|
932
|
+
toolType,
|
|
933
|
+
status: 'rejected',
|
|
934
|
+
});
|
|
935
|
+
return {
|
|
936
|
+
id: call.id,
|
|
937
|
+
name: call.name,
|
|
938
|
+
output: { rejected: true, reason: decision.reason ?? 'rejected by user' },
|
|
939
|
+
error: 'rejected',
|
|
940
|
+
};
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
return recordToolOutcome(turn, claimed, await invokeClaimed(turn, claimed));
|
|
944
|
+
}
|
|
945
|
+
/**
|
|
946
|
+
* Overlap the INVOCATIONS of a batch of claimed calls, and only those. The persist checkpoints on
|
|
947
|
+
* either side stay strictly sequential, in call order — they are asked for after their neighbours'
|
|
948
|
+
* positions are already spent, so nothing about them depends on which tool finished first.
|
|
949
|
+
*
|
|
950
|
+
* A rejection here is the runner unwinding the turn, never a tool's own failure
|
|
951
|
+
* ({@link invokeClaimedTool} reports those). Rethrow the first one in call order and persist
|
|
952
|
+
* NOTHING: the resume replays this whole block, and a `persist:toolexec` written now would sit at
|
|
953
|
+
* the position the replay computes for an earlier call's.
|
|
954
|
+
*/
|
|
955
|
+
async function invokeClaimedToolsTogether(turn, parallel, claimed) {
|
|
956
|
+
const settled = await parallel(claimed.map((entry) => () => invokeClaimed(turn, entry)));
|
|
957
|
+
for (const outcome of settled) {
|
|
958
|
+
if (!outcome.ok) {
|
|
959
|
+
throw outcome.error;
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
const results = [];
|
|
963
|
+
for (const [index, entry] of claimed.entries()) {
|
|
964
|
+
const outcome = settled[index];
|
|
965
|
+
if (outcome?.ok === true) {
|
|
966
|
+
results.push(await recordToolOutcome(turn, entry, outcome.value));
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
return results;
|
|
970
|
+
}
|
|
971
|
+
/**
|
|
972
|
+
* What the formatting pass is shown. By default the QUESTION and the ANSWER — the pass is a
|
|
973
|
+
* translation of the answer, and its own instruction tells the model to use only what the
|
|
974
|
+
* conversation already contains, so handing it the whole transcript again would roughly double what
|
|
975
|
+
* a turn with an `outputSchema` costs for nothing. See {@link AgentLoopDeps.outputFromTranscript}
|
|
976
|
+
* for the agent that genuinely needs more, and why it is off by default.
|
|
977
|
+
*
|
|
978
|
+
* The question comes off the PROCESSED prompt rather than `AgentRunInput.userText`: this pass is a
|
|
979
|
+
* second route out of the model and has to stand behind the same input chain the streamed turn did,
|
|
980
|
+
* so a question a processor masked cannot reappear in the clear here.
|
|
981
|
+
*/
|
|
982
|
+
function restatementPrompt(args) {
|
|
983
|
+
const restated = { role: 'assistant', content: args.answer };
|
|
984
|
+
if (args.fromTranscript) {
|
|
985
|
+
return [...args.messages, restated];
|
|
986
|
+
}
|
|
987
|
+
// The last user message that actually SAYS something. The loop feeds a step's tool results back
|
|
988
|
+
// as an empty `user` message carrying `toolResults`, so scanning for the last `user` role alone
|
|
989
|
+
// would hand the pass a blank question on every turn that called a tool.
|
|
990
|
+
for (let index = args.messages.length - 1; index >= 0; index -= 1) {
|
|
991
|
+
const message = args.messages[index];
|
|
992
|
+
if (message?.role === 'user' && message.content.length > 0) {
|
|
993
|
+
return [message, restated];
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
return [restated];
|
|
997
|
+
}
|
|
998
|
+
/**
|
|
999
|
+
* Restate a finished answer as `deps.outputSchema`, repairing a rejected reply up to
|
|
1000
|
+
* `outputRepairAttempts` times. Every model call is its own checkpoint (`structured:<step>:<n>`)
|
|
1001
|
+
* with its own usage row, so a suspend between two attempts resumes on the reply the first one got
|
|
1002
|
+
* rather than paying for a third.
|
|
1003
|
+
*
|
|
1004
|
+
* Validation itself sits OUTSIDE the checkpoints, on the same footing as `HistoryWindow.select`: its
|
|
1005
|
+
* inputs are the schema (module config, identical on every process of a deployment) and a reply a
|
|
1006
|
+
* checkpoint already holds, so every replay reaches the same verdict — and therefore the same number
|
|
1007
|
+
* of attempts — without a checkpoint of its own.
|
|
1008
|
+
*
|
|
1009
|
+
* THE OUTPUT CHAIN RULES ON EACH ATTEMPT, before the schema does. This pass is a second route out of
|
|
1010
|
+
* the model, so anything the chain held back from the prose must not be able to leave through here.
|
|
1011
|
+
* It is gated on the WHOLE reply regardless of what the chain declared, because nothing here is
|
|
1012
|
+
* streamed — there is no prefix for an `incremental` processor to release.
|
|
1013
|
+
*/
|
|
1014
|
+
async function structureAnswer(args) {
|
|
1015
|
+
const { schema, deps, input, hooks, messages, step, ctx } = args;
|
|
1016
|
+
const instruction = deps.outputInstruction ?? DEFAULT_STRUCTURED_OUTPUT_INSTRUCTION;
|
|
1017
|
+
const maxAttempts = 1 + (deps.outputRepairAttempts ?? 1);
|
|
1018
|
+
const outputProcessors = deps.outputProcessors ?? [];
|
|
1019
|
+
let issues = [];
|
|
1020
|
+
let text = '';
|
|
1021
|
+
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
|
1022
|
+
const system = attempt === 0 ? instruction : repairInstruction({ instruction, issues });
|
|
1023
|
+
const reply = await hooks.step(`structured:${step}:${attempt}`, async () => {
|
|
1024
|
+
// A sink that drops every frame: this call is not the turn's answer, so its tokens must never
|
|
1025
|
+
// reach the reader's stream alongside the prose they are restating.
|
|
1026
|
+
const discard = { write: () => { }, end: () => { } };
|
|
1027
|
+
const turn = await deps.model.runTurn({
|
|
1028
|
+
system,
|
|
1029
|
+
messages,
|
|
1030
|
+
tools: [],
|
|
1031
|
+
sink: discard,
|
|
1032
|
+
outputSchema: schema,
|
|
1033
|
+
});
|
|
1034
|
+
return {
|
|
1035
|
+
text: turn.text,
|
|
1036
|
+
usage: turn.usage,
|
|
1037
|
+
...(turn.object !== undefined ? { object: turn.object } : {}),
|
|
1038
|
+
...(turn.modelId !== undefined ? { modelId: turn.modelId } : {}),
|
|
1039
|
+
};
|
|
1040
|
+
});
|
|
1041
|
+
await hooks.step(`persist:usage:structured:${step}:${attempt}`, () => deps.store.recordUsage({
|
|
1042
|
+
threadId: input.threadId,
|
|
1043
|
+
actorRef: input.actor.id,
|
|
1044
|
+
runId: hooks.runId,
|
|
1045
|
+
modelId: reply.modelId ?? deps.modelId ?? 'unknown',
|
|
1046
|
+
purpose: 'structured_output',
|
|
1047
|
+
usage: reply.usage,
|
|
1048
|
+
}));
|
|
1049
|
+
text = reply.text;
|
|
1050
|
+
if (outputProcessors.length > 0) {
|
|
1051
|
+
// Its own checkpoint, after the usage row: the tokens were spent either way, and a refusal
|
|
1052
|
+
// that also hid its own cost would let a mis-tuned gate burn a budget invisibly — the same
|
|
1053
|
+
// ordering the streamed answer's gate takes.
|
|
1054
|
+
const gate = await hooks.step(`process:output:structured:${step}:${attempt}`, () => runOutputProcessors({
|
|
1055
|
+
processors: outputProcessors,
|
|
1056
|
+
answer: { text: reply.text, toolCalls: [] },
|
|
1057
|
+
ctx,
|
|
1058
|
+
}));
|
|
1059
|
+
if (gate.rejection !== undefined) {
|
|
1060
|
+
throw new OutputRejectedError(gate.rejection.processor, gate.rejection.reason);
|
|
1061
|
+
}
|
|
1062
|
+
text = gate.text;
|
|
1063
|
+
}
|
|
1064
|
+
// A provider that constrained its own generation reports a parsed `object`, but that object
|
|
1065
|
+
// describes the reply the chain has just rewritten. Once the two disagree, the gated text is the
|
|
1066
|
+
// only version anything downstream may see, so it is re-parsed rather than trusted.
|
|
1067
|
+
const reported = text === reply.text ? reply.object : undefined;
|
|
1068
|
+
const outcome = await validateStructured({ schema, text, reported });
|
|
1069
|
+
if (outcome.ok) {
|
|
1070
|
+
return outcome.value;
|
|
1071
|
+
}
|
|
1072
|
+
issues = outcome.issues;
|
|
1073
|
+
}
|
|
1074
|
+
throw new StructuredOutputError(issues, text, maxAttempts);
|
|
1075
|
+
}
|
|
78
1076
|
/**
|
|
79
1077
|
* The provider-agnostic agent turn, reused by both the inline and durable runners.
|
|
80
1078
|
* It drives the model→tools→model iteration; the runner supplies the `step`/`awaitApproval`
|
|
@@ -84,6 +1082,17 @@ export async function runAgentLoop(deps, input, hooks) {
|
|
|
84
1082
|
const maxSteps = deps.maxSteps ?? 8;
|
|
85
1083
|
const persona = input.persona;
|
|
86
1084
|
let system = await resolveSystemPrompt(deps, input);
|
|
1085
|
+
const inputProcessors = deps.inputProcessors ?? [];
|
|
1086
|
+
const outputProcessors = deps.outputProcessors ?? [];
|
|
1087
|
+
const gateMode = resolveOutputGateMode(outputProcessors);
|
|
1088
|
+
const gateLookback = resolveGateLookback(outputProcessors);
|
|
1089
|
+
const processorContext = (step) => ({
|
|
1090
|
+
threadId: input.threadId,
|
|
1091
|
+
actor: input.actor,
|
|
1092
|
+
step,
|
|
1093
|
+
...(input.agentName !== undefined ? { agentName: input.agentName } : {}),
|
|
1094
|
+
...(persona !== undefined ? { persona: persona.id } : {}),
|
|
1095
|
+
});
|
|
87
1096
|
// Open the run (turn) row FIRST — before the quota gate — so even a quota-rejected run is tracked
|
|
88
1097
|
// (the runner settles it `failed`). A checkpointed step so a durable replay reuses the ONE row (no
|
|
89
1098
|
// duplicate on resume); `run_id` is then stamped onto every message / tool call / usage row below.
|
|
@@ -93,6 +1102,7 @@ export async function runAgentLoop(deps, input, hooks) {
|
|
|
93
1102
|
actor: input.actor,
|
|
94
1103
|
durable: hooks.durable ?? false,
|
|
95
1104
|
...(input.agentName !== undefined ? { agentName: input.agentName } : {}),
|
|
1105
|
+
...(input.parentRunId !== undefined ? { parentRunId: input.parentRunId } : {}),
|
|
96
1106
|
}));
|
|
97
1107
|
if (deps.quota !== undefined) {
|
|
98
1108
|
const quota = deps.quota;
|
|
@@ -112,23 +1122,21 @@ export async function runAgentLoop(deps, input, hooks) {
|
|
|
112
1122
|
...(persona !== undefined ? { persona: persona.id } : {}),
|
|
113
1123
|
...(input.attachments !== undefined ? { attachments: input.attachments } : {}),
|
|
114
1124
|
}));
|
|
115
|
-
const thread = await hooks.step('load:thread', () => deps.
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
}))
|
|
131
|
-
: fullHistory;
|
|
1125
|
+
const thread = threadForTurn(await hooks.step('load:thread', () => readThreadForTurn(deps, input.threadId, turnMessageLimit(deps.historyWindow))));
|
|
1126
|
+
// With no window configured this whole block is skipped, so an existing deployment sends the
|
|
1127
|
+
// messages `load:thread` returned and records byte-identical checkpoints.
|
|
1128
|
+
let modelMessages = thread.messages;
|
|
1129
|
+
if (deps.historyWindow !== undefined) {
|
|
1130
|
+
const window = deps.historyWindow;
|
|
1131
|
+
modelMessages = (await (hooks.patched?.(HISTORY_SELECT_PATCH) ?? Promise.resolve(true)))
|
|
1132
|
+
? await applyHistoryWindow(window, deps, input, hooks, thread.messages, hooks.step)
|
|
1133
|
+
: // A run whose history holds the single `history:window` checkpoint replays against it: a
|
|
1134
|
+
// completed one returns its recorded messages without re-running anything, and one that has
|
|
1135
|
+
// yet to reach it runs the whole fold INSIDE that one checkpoint — nested checkpoints would
|
|
1136
|
+
// take positions the recorded shape never had, and go missing the moment the outer one
|
|
1137
|
+
// replays from cache.
|
|
1138
|
+
await hooks.step('history:window', () => applyHistoryWindow(window, deps, input, hooks, thread.messages, (_name, fn) => fn()));
|
|
1139
|
+
}
|
|
132
1140
|
const writer = await hooks.openSink();
|
|
133
1141
|
let lastText = '';
|
|
134
1142
|
let steps = 0;
|
|
@@ -138,12 +1146,45 @@ export async function runAgentLoop(deps, input, hooks) {
|
|
|
138
1146
|
// priced). If NO step had a cost, the run's `cost_usd` stays `null` — never a fabricated `0`.
|
|
139
1147
|
let totalCost = 0;
|
|
140
1148
|
let hasCost = false;
|
|
1149
|
+
let structured;
|
|
141
1150
|
publishAgentRunStarted({
|
|
142
1151
|
runId: hooks.runId,
|
|
143
1152
|
threadId: input.threadId,
|
|
144
1153
|
actorId: input.actor.id,
|
|
145
1154
|
...(persona !== undefined ? { persona: persona.id } : {}),
|
|
146
1155
|
});
|
|
1156
|
+
// What the assistant already believes about this person, folded in ABOVE the retrieved passages
|
|
1157
|
+
// and the skills catalog — most durable first, so a reader meets the standing facts before the
|
|
1158
|
+
// passages this one question pulled.
|
|
1159
|
+
//
|
|
1160
|
+
// One checkpoint, holding the WHOLE digest: the scopes the resolver returned and the entries that
|
|
1161
|
+
// survived selection, precedence and the ceiling. That payload is what the block is rendered from
|
|
1162
|
+
// AND what a later `remember` call is authorized against — so which memories entered this prompt,
|
|
1163
|
+
// and which scopes this turn could write, are facts the journal holds rather than answers a
|
|
1164
|
+
// replaying process's provider would give afresh. Where the host runs an index, the SEARCH happens
|
|
1165
|
+
// in here too: a ranking is the most re-derivable decision in this library, so a replay has to
|
|
1166
|
+
// read its result back rather than ask an index that has since moved.
|
|
1167
|
+
//
|
|
1168
|
+
// THE QUERY IS THE USER'S OWN MESSAGE, and nothing else. It is the only thing available before the
|
|
1169
|
+
// first model call — which is where memory has to be, since a memory is worthless unless it is in
|
|
1170
|
+
// front of the model on the turn nobody thought to look — and it is already a journaled input to
|
|
1171
|
+
// this run, so it needs no determinism machinery of its own. What it fails at is a turn with no
|
|
1172
|
+
// topic ("and the other thing?"): `pinned` is the answer to those, not a cleverer query.
|
|
1173
|
+
let memoryDigest;
|
|
1174
|
+
if (deps.memory !== undefined) {
|
|
1175
|
+
const config = deps.memory;
|
|
1176
|
+
memoryDigest = await hooks.step('memory:digest', () => offerMemories({ config, ctx: skillContext(input), query: input.userText }));
|
|
1177
|
+
// No entries, no block: an actor the assistant has concluded nothing about pays nothing, rather
|
|
1178
|
+
// than reading a heading over an empty list and inferring something from its presence.
|
|
1179
|
+
const digest = memoryDigest;
|
|
1180
|
+
if (digest.entries.length > 0) {
|
|
1181
|
+
system = `${system}\n\n${buildMemoryBlock({
|
|
1182
|
+
entries: digest.entries,
|
|
1183
|
+
writable: memoryIsWritable(deps),
|
|
1184
|
+
partial: digest.omitted > 0 || digest.recalled === true,
|
|
1185
|
+
})}`;
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
147
1188
|
// Inject-mode RAG: retrieve once for the user message and fold the passages into the system prompt.
|
|
148
1189
|
// Wrapped in `hooks.step` so a durable replay reuses the SAME passages (no re-query, deterministic
|
|
149
1190
|
// prompt). Recorded below as a synthetic auto-executed `retrieve` tool call on the first assistant
|
|
@@ -206,6 +1247,25 @@ export async function runAgentLoop(deps, input, hooks) {
|
|
|
206
1247
|
count: passages.length,
|
|
207
1248
|
});
|
|
208
1249
|
}
|
|
1250
|
+
// The skills catalog, LAST of the things that write the system block (base prompt, persona,
|
|
1251
|
+
// retrieved context, this) — so a reader of the assembled prompt meets the agent's own
|
|
1252
|
+
// instructions before the menu of ones it could go and fetch.
|
|
1253
|
+
//
|
|
1254
|
+
// One checkpoint, holding the WHOLE offer: the scopes the resolver returned and the entries that
|
|
1255
|
+
// survived precedence. That payload is what the block is rendered from and what a later `skill`
|
|
1256
|
+
// call is served against, so the two things a turn's prompt depends on — which scopes applied, and
|
|
1257
|
+
// which skills they yielded — are facts the journal holds rather than answers a replaying
|
|
1258
|
+
// process's provider would give afresh.
|
|
1259
|
+
let skillOffer;
|
|
1260
|
+
if (deps.skills !== undefined) {
|
|
1261
|
+
const config = deps.skills;
|
|
1262
|
+
skillOffer = await hooks.step('skills:catalog', () => offerSkills(config, skillContext(input)));
|
|
1263
|
+
// No entries, no block: an actor whose scopes yield nothing pays nothing, rather than reading a
|
|
1264
|
+
// heading over an empty list and wondering what it was for.
|
|
1265
|
+
if (skillOffer.entries.length > 0) {
|
|
1266
|
+
system = `${system}\n\n${buildSkillsBlock(skillOffer.entries)}`;
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
209
1269
|
// Fetched ONCE per run (not per step) and reused for every step's cost estimate below. Returned as
|
|
210
1270
|
// a plain array (not the Map built from it) so a durable runner can JSON-cache the step's result.
|
|
211
1271
|
let prices = [];
|
|
@@ -214,21 +1274,123 @@ export async function runAgentLoop(deps, input, hooks) {
|
|
|
214
1274
|
prices = await hooks.step('pricing:list', () => pricingStore.listCurrentPrices());
|
|
215
1275
|
}
|
|
216
1276
|
const priceByModel = new Map(prices.map((price) => [price.modelId, price]));
|
|
1277
|
+
// The configured intake, LAST before the first model call: everything the run records about itself
|
|
1278
|
+
// (the run row, retrieval, prices) is settled before the turn parks on a person, who may take a
|
|
1279
|
+
// day. The whole block is reachable only through `deps.intake`.
|
|
1280
|
+
if (deps.intake !== undefined) {
|
|
1281
|
+
const asked = await runIntake({
|
|
1282
|
+
intake: deps.intake,
|
|
1283
|
+
deps,
|
|
1284
|
+
input,
|
|
1285
|
+
hooks,
|
|
1286
|
+
writer,
|
|
1287
|
+
// Whether this thread had already been answered when the turn began — taken off
|
|
1288
|
+
// `load:thread`'s CACHED result so a replay decides the intake the way the first attempt did.
|
|
1289
|
+
threadHasAssistant: thread.hasAssistantMessage,
|
|
1290
|
+
});
|
|
1291
|
+
if (asked !== null) {
|
|
1292
|
+
modelMessages.push(asked);
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
217
1295
|
// NOTE: no try/finally around this loop. A durable runner suspends by THROWING through the stack
|
|
218
1296
|
// at `awaitApproval` (ctx.waitForSignal); a finally would then call writer.end() on every suspend
|
|
219
1297
|
// and prematurely close the live stream. We only end on normal completion — the throw propagates
|
|
220
1298
|
// to the engine, and the resumed replay reaches the writer.end() below.
|
|
221
1299
|
for (let i = 0; i < maxSteps; i += 1) {
|
|
222
|
-
const tools =
|
|
1300
|
+
const tools = withBuiltInTools({
|
|
1301
|
+
tools: await deps.registry.definitionsFor(input.actor, deps.rolesPolicy, intersectAllow(persona?.allowedTools, deps.toolAllowList)),
|
|
1302
|
+
ask: deps.ask,
|
|
1303
|
+
skills: skillOffer !== undefined,
|
|
1304
|
+
memory: memoryDigest !== undefined && memoryIsWritable(deps),
|
|
1305
|
+
});
|
|
1306
|
+
// Every step, not once per run: the transcript grows between steps, so a processor that only saw
|
|
1307
|
+
// the opening prompt would wave through whatever a tool result carried back. The result is a
|
|
1308
|
+
// per-step DERIVED prompt — `modelMessages` stays the loop's canonical transcript, so a redaction
|
|
1309
|
+
// never becomes the thread's own memory of what was said.
|
|
1310
|
+
let prompt = { system, messages: modelMessages };
|
|
1311
|
+
if (inputProcessors.length > 0) {
|
|
1312
|
+
prompt = await hooks.step(`process:input:${i}`, () => runInputProcessors({ processors: inputProcessors, prompt, ctx: processorContext(i) }));
|
|
1313
|
+
}
|
|
223
1314
|
// Span the model call INSIDE the step body (replay-safe). The turn's raw text/output never rides
|
|
224
1315
|
// the span — only token counts + name/length metadata (the point events' redaction posture).
|
|
225
|
-
|
|
1316
|
+
let turn = await hooks.step(`llm:${i}`, () => spannedAgent('llm.turn', hooks.runId, { runId: hooks.runId, step: i }, async () => {
|
|
1317
|
+
const incremental = gateMode === 'incremental'
|
|
1318
|
+
? createIncrementalGate({
|
|
1319
|
+
processors: outputProcessors,
|
|
1320
|
+
ctx: processorContext(i),
|
|
1321
|
+
lookbackChars: gateLookback,
|
|
1322
|
+
writer,
|
|
1323
|
+
})
|
|
1324
|
+
: undefined;
|
|
1325
|
+
const buffer = gateMode === 'whole' ? createFrameBuffer() : undefined;
|
|
1326
|
+
const result = await deps.model.runTurn({
|
|
1327
|
+
system: prompt.system,
|
|
1328
|
+
messages: prompt.messages,
|
|
1329
|
+
tools,
|
|
1330
|
+
sink: incremental?.writer ?? buffer?.writer ?? writer,
|
|
1331
|
+
});
|
|
1332
|
+
if (incremental !== undefined) {
|
|
1333
|
+
await incremental.settled();
|
|
1334
|
+
const refusal = incremental.rejection();
|
|
1335
|
+
return {
|
|
1336
|
+
...result,
|
|
1337
|
+
releasedText: incremental.released(),
|
|
1338
|
+
...(refusal !== undefined ? { gateRejection: refusal } : {}),
|
|
1339
|
+
};
|
|
1340
|
+
}
|
|
1341
|
+
return buffer === undefined ? result : { ...result, bufferedFrames: buffer.frames() };
|
|
1342
|
+
}, (result) => ({
|
|
226
1343
|
...(result.modelId !== undefined ? { modelId: result.modelId } : {}),
|
|
227
1344
|
inputTokens: result.usage.inputTokens,
|
|
228
1345
|
outputTokens: result.usage.outputTokens,
|
|
229
1346
|
textLength: result.text.length,
|
|
230
1347
|
toolCalls: result.toolCalls.length,
|
|
231
1348
|
})));
|
|
1349
|
+
// The gate runs before ANYTHING downstream: before the held frames reach the subscriber, before
|
|
1350
|
+
// the assistant message is persisted, before this text becomes the next step's context.
|
|
1351
|
+
let rejection;
|
|
1352
|
+
if (gateMode !== 'off') {
|
|
1353
|
+
const gate = await hooks.step(`process:output:${i}`, async () => {
|
|
1354
|
+
// Which release this turn still owes is read from the JOURNALED model result, never from the
|
|
1355
|
+
// chain configured on whichever process is running now: `releasedText` is the record that a
|
|
1356
|
+
// prefix already reached the reader, so a run that resumes under a re-declared chain cannot
|
|
1357
|
+
// flush the same answer a second time.
|
|
1358
|
+
const released = turn.releasedText;
|
|
1359
|
+
// A refusal an incremental gate already reached. Re-running the chain would bill the same
|
|
1360
|
+
// verdict twice.
|
|
1361
|
+
if (turn.gateRejection !== undefined) {
|
|
1362
|
+
return { text: turn.text, rejection: turn.gateRejection };
|
|
1363
|
+
}
|
|
1364
|
+
const settled = await runOutputProcessors({
|
|
1365
|
+
processors: outputProcessors,
|
|
1366
|
+
answer: { text: turn.text, toolCalls: turn.toolCalls },
|
|
1367
|
+
ctx: processorContext(i),
|
|
1368
|
+
});
|
|
1369
|
+
// Releasing inside the same checkpoint as the verdict is what makes "refused" and "nothing
|
|
1370
|
+
// was streamed" one fact rather than two that a suspend could separate. For an incremental
|
|
1371
|
+
// turn the second half of that is weaker by construction — a prefix is already out — and the
|
|
1372
|
+
// whole-answer pass stays authoritative for both the stream and the store.
|
|
1373
|
+
if (settled.rejection === undefined) {
|
|
1374
|
+
if (released === undefined) {
|
|
1375
|
+
for (const frame of releaseGatedFrames({
|
|
1376
|
+
frames: turn.bufferedFrames ?? [],
|
|
1377
|
+
text: settled.text,
|
|
1378
|
+
})) {
|
|
1379
|
+
await writer.write(frame);
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
else {
|
|
1383
|
+
const tail = gateTail({ processors: outputProcessors, released, text: settled.text });
|
|
1384
|
+
if (tail.length > 0) {
|
|
1385
|
+
await writer.write({ t: 'text', v: tail });
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
return settled;
|
|
1390
|
+
});
|
|
1391
|
+
rejection = gate.rejection;
|
|
1392
|
+
turn = { ...turn, text: gate.text };
|
|
1393
|
+
}
|
|
232
1394
|
// provider-reported model wins over the configured fallback, so cost can't misattribute
|
|
233
1395
|
const resolvedModelId = turn.modelId ?? deps.modelId ?? 'unknown';
|
|
234
1396
|
// Provider-reported spend wins; else an estimate from the (once-per-run cached) price list; else
|
|
@@ -254,16 +1416,72 @@ export async function runAgentLoop(deps, input, hooks) {
|
|
|
254
1416
|
const quota = deps.quota;
|
|
255
1417
|
await hooks.step(`quota:bump:${i}`, () => quota.bump(input.actor.id, deps.day, turn.usage.inputTokens + turn.usage.outputTokens));
|
|
256
1418
|
}
|
|
1419
|
+
// Thrown only AFTER the two accounting checkpoints above: those tokens were genuinely spent, and
|
|
1420
|
+
// a refusal that also hid its own cost would let a mis-tuned gate burn a budget invisibly.
|
|
1421
|
+
if (rejection !== undefined) {
|
|
1422
|
+
throw new OutputRejectedError(rejection.processor, rejection.reason);
|
|
1423
|
+
}
|
|
257
1424
|
steps += 1;
|
|
258
1425
|
totalInput += turn.usage.inputTokens;
|
|
259
1426
|
totalOutput += turn.usage.outputTokens;
|
|
260
1427
|
lastText = turn.text;
|
|
1428
|
+
// Restating the GATED text, never the model's raw reply: the formatting pass is a translation of
|
|
1429
|
+
// the answer that survived the output chain, not a second route out of the model.
|
|
1430
|
+
if (turn.toolCalls.length === 0 && deps.outputSchema !== undefined) {
|
|
1431
|
+
structured = await structureAnswer({
|
|
1432
|
+
schema: deps.outputSchema,
|
|
1433
|
+
deps,
|
|
1434
|
+
input,
|
|
1435
|
+
hooks,
|
|
1436
|
+
messages: restatementPrompt({
|
|
1437
|
+
messages: prompt.messages,
|
|
1438
|
+
answer: turn.text,
|
|
1439
|
+
fromTranscript: deps.outputFromTranscript === true,
|
|
1440
|
+
}),
|
|
1441
|
+
step: i,
|
|
1442
|
+
ctx: processorContext(i),
|
|
1443
|
+
});
|
|
1444
|
+
}
|
|
261
1445
|
publishAgentMessage({
|
|
262
1446
|
runId: hooks.runId,
|
|
263
1447
|
threadId: input.threadId,
|
|
264
1448
|
role: 'assistant',
|
|
265
1449
|
textLength: turn.text.length,
|
|
266
1450
|
});
|
|
1451
|
+
// Inject-mode retrieval and the structured answer both reach a reader as ordinary tool calls.
|
|
1452
|
+
// Their ids come off the RUN rather than the message they hang from, because the message does
|
|
1453
|
+
// not exist yet and both have to be on the append below: a reader pairs a call with its result
|
|
1454
|
+
// off the message, so a call added afterwards renders as a tool still running. Every value here
|
|
1455
|
+
// is already settled by a checkpoint this turn ran (`retrieve`, `structured:<step>:<n>`), so a
|
|
1456
|
+
// replay rebuilds the same pair rather than minting one.
|
|
1457
|
+
const synthetic = [];
|
|
1458
|
+
if (i === 0 && injectedPassages !== undefined) {
|
|
1459
|
+
const call = {
|
|
1460
|
+
id: `retrieve-${hooks.runId}`,
|
|
1461
|
+
name: 'retrieve',
|
|
1462
|
+
input: { query: input.userText },
|
|
1463
|
+
};
|
|
1464
|
+
const output = { passages: injectedPassages };
|
|
1465
|
+
synthetic.push({
|
|
1466
|
+
step: 'persist:retrieval',
|
|
1467
|
+
call,
|
|
1468
|
+
result: { id: call.id, name: call.name, output },
|
|
1469
|
+
});
|
|
1470
|
+
}
|
|
1471
|
+
if (structured !== undefined) {
|
|
1472
|
+
const call = {
|
|
1473
|
+
id: `structured-${hooks.runId}`,
|
|
1474
|
+
name: 'structured_output',
|
|
1475
|
+
input: {},
|
|
1476
|
+
};
|
|
1477
|
+
synthetic.push({
|
|
1478
|
+
step: 'persist:structured',
|
|
1479
|
+
call,
|
|
1480
|
+
result: { id: call.id, name: call.name, output: structured },
|
|
1481
|
+
});
|
|
1482
|
+
}
|
|
1483
|
+
const messageCalls = [...turn.toolCalls, ...synthetic.map((entry) => entry.call)];
|
|
1484
|
+
const syntheticResults = synthetic.map((entry) => entry.result);
|
|
267
1485
|
const assistant = await hooks.step(`persist:assistant:${i}`, () => deps.store.appendMessage({
|
|
268
1486
|
threadId: input.threadId,
|
|
269
1487
|
role: 'assistant',
|
|
@@ -271,219 +1489,104 @@ export async function runAgentLoop(deps, input, hooks) {
|
|
|
271
1489
|
runId: hooks.runId,
|
|
272
1490
|
usage: { ...turn.usage, costUsd },
|
|
273
1491
|
...(persona !== undefined ? { persona: persona.id } : {}),
|
|
274
|
-
...(
|
|
1492
|
+
...(messageCalls.length > 0 ? { toolCalls: messageCalls } : {}),
|
|
1493
|
+
...(syntheticResults.length > 0 ? { toolResults: syntheticResults } : {}),
|
|
275
1494
|
}));
|
|
276
1495
|
modelMessages.push({
|
|
277
1496
|
role: 'assistant',
|
|
278
1497
|
content: turn.text,
|
|
279
|
-
...(
|
|
1498
|
+
...(messageCalls.length > 0 ? { toolCalls: messageCalls } : {}),
|
|
1499
|
+
...(syntheticResults.length > 0 ? { toolResults: syntheticResults } : {}),
|
|
280
1500
|
});
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
if (i === 0 && injectedPassages !== undefined) {
|
|
285
|
-
const passages = injectedPassages;
|
|
286
|
-
const toolCallId = `retrieve-${assistant.id}`;
|
|
287
|
-
await hooks.step(`persist:retrieval:${assistant.id}`, async () => {
|
|
1501
|
+
for (const entry of synthetic) {
|
|
1502
|
+
const { call, result } = entry;
|
|
1503
|
+
await hooks.step(`${entry.step}:${assistant.id}`, async () => {
|
|
288
1504
|
await deps.store.recordToolCall({
|
|
289
|
-
toolCallId,
|
|
1505
|
+
toolCallId: call.id,
|
|
290
1506
|
messageId: assistant.id,
|
|
291
|
-
toolName:
|
|
1507
|
+
toolName: call.name,
|
|
292
1508
|
toolType: 'read',
|
|
293
|
-
input:
|
|
1509
|
+
input: call.input,
|
|
294
1510
|
status: 'auto_executed',
|
|
295
1511
|
runId: hooks.runId,
|
|
296
1512
|
});
|
|
297
|
-
await deps.store.updateToolCall({
|
|
1513
|
+
await deps.store.updateToolCall({
|
|
1514
|
+
toolCallId: call.id,
|
|
1515
|
+
status: 'executed',
|
|
1516
|
+
output: result.output,
|
|
1517
|
+
});
|
|
298
1518
|
});
|
|
299
1519
|
}
|
|
300
1520
|
if (turn.toolCalls.length === 0) {
|
|
301
1521
|
break;
|
|
302
1522
|
}
|
|
303
1523
|
const results = [];
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
}
|
|
339
|
-
const offeredAllow = intersectAllow(persona?.allowedTools, deps.toolAllowList);
|
|
340
|
-
if (offeredAllow !== undefined && !offeredAllow.includes(call.name)) {
|
|
341
|
-
// A tool the model was not offered must not run, even if the model names it anyway.
|
|
342
|
-
throw new ToolForbiddenError(call.name);
|
|
343
|
-
}
|
|
344
|
-
if (!(await deps.rolesPolicy.can(input.actor, spec))) {
|
|
345
|
-
throw new ToolForbiddenError(call.name);
|
|
346
|
-
}
|
|
347
|
-
// The third gate `ToolRegistry.invoke` applies, in the same order: authorization first,
|
|
348
|
-
// then shape. Delegation bypasses `invoke` (it is a ctx-level suspend point), so the gate
|
|
349
|
-
// has to be re-applied here or a malformed input is silently coerced instead of rejected.
|
|
350
|
-
const validation = await spec.inputSchema['~standard'].validate(call.input);
|
|
351
|
-
if (validation.issues !== undefined) {
|
|
352
|
-
throw new ToolInputInvalidError(call.name, validation.issues);
|
|
353
|
-
}
|
|
354
|
-
task = extractTask(validation.value);
|
|
355
|
-
}
|
|
356
|
-
catch (error) {
|
|
357
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
358
|
-
// A denied delegation is recorded `failed` directly — never `auto_executed` even
|
|
359
|
-
// transiently — and never emits `agent.delegated`.
|
|
360
|
-
await hooks.step(`persist:toolcall:${call.id}`, () => deps.store.recordToolCall({
|
|
361
|
-
toolCallId: call.id,
|
|
362
|
-
messageId: assistant.id,
|
|
363
|
-
toolName: call.name,
|
|
364
|
-
toolType: 'read',
|
|
365
|
-
input: call.input,
|
|
366
|
-
status: 'failed',
|
|
367
|
-
runId: hooks.runId,
|
|
368
|
-
}));
|
|
369
|
-
results.push({ id: call.id, name: call.name, output: null, error: message });
|
|
370
|
-
continue;
|
|
371
|
-
}
|
|
372
|
-
await hooks.step(`persist:toolcall:${call.id}`, () => deps.store.recordToolCall({
|
|
373
|
-
toolCallId: call.id,
|
|
374
|
-
messageId: assistant.id,
|
|
375
|
-
toolName: call.name,
|
|
376
|
-
toolType: 'read',
|
|
377
|
-
input: call.input,
|
|
378
|
-
status: 'auto_executed',
|
|
379
|
-
runId: hooks.runId,
|
|
380
|
-
}));
|
|
381
|
-
publishAgentDelegated({
|
|
382
|
-
runId: hooks.runId,
|
|
383
|
-
toAgent: targetAgent,
|
|
384
|
-
...(input.agentName !== undefined ? { fromAgent: input.agentName } : {}),
|
|
385
|
-
});
|
|
386
|
-
const sub = hooks.runAgent
|
|
387
|
-
? await hooks.runAgent(targetAgent, task)
|
|
388
|
-
: { text: `(no multi-agent support wired; cannot reach "${targetAgent}")` };
|
|
389
|
-
await hooks.step(`persist:toolexec:${call.id}`, () => deps.store.updateToolCall({ toolCallId: call.id, status: 'executed', output: sub }));
|
|
390
|
-
results.push({ id: call.id, name: call.name, output: sub });
|
|
391
|
-
continue;
|
|
1524
|
+
const turnCalls = {
|
|
1525
|
+
deps,
|
|
1526
|
+
input,
|
|
1527
|
+
hooks,
|
|
1528
|
+
messageId: assistant.id,
|
|
1529
|
+
writer,
|
|
1530
|
+
...(skillOffer !== undefined ? { skills: skillOffer } : {}),
|
|
1531
|
+
...(memoryDigest !== undefined ? { memory: memoryDigest } : {}),
|
|
1532
|
+
};
|
|
1533
|
+
// A model routinely asks for several tools at once, and running them back to back makes the
|
|
1534
|
+
// turn cost their sum. Overlapping them is safe here because a checkpoint position is handed
|
|
1535
|
+
// out on the CALL, not when the work settles: launching every invocation in one tick — what
|
|
1536
|
+
// `hooks.parallel` promises — pins the `tool:` block in call order however the tools then
|
|
1537
|
+
// finish. The claim and persist checkpoints stay sequential around it.
|
|
1538
|
+
//
|
|
1539
|
+
// Only a turn whose every call is a plain `read` qualifies:
|
|
1540
|
+
// - an `action` suspends on an approval signal, which is human time rather than I/O, so
|
|
1541
|
+
// overlapping what follows it buys nothing — and reserving an invocation position for a
|
|
1542
|
+
// call that may yet be REJECTED spends a position the rejected branch never fills.
|
|
1543
|
+
// - an `agent` delegation is `ctx.child`, whose parallel form is the runtime's own `ctx.all`:
|
|
1544
|
+
// one workflow ref over a reserved block, carrying the `parallelGroup` bookkeeping that
|
|
1545
|
+
// makes a fan render as a fan. That is not N independent task closures, so it cannot ride
|
|
1546
|
+
// this hook.
|
|
1547
|
+
// The kinds come from the `persist:toolcall` checkpoints, never from a local registry lookup,
|
|
1548
|
+
// so every process replaying this turn reaches the same verdict.
|
|
1549
|
+
const parallel = hooks.parallel;
|
|
1550
|
+
if (parallel !== undefined &&
|
|
1551
|
+
turn.toolCalls.length > 1 &&
|
|
1552
|
+
(await (hooks.patched?.(PARALLEL_TOOLS_PATCH) ?? Promise.resolve(true)))) {
|
|
1553
|
+
// Claiming the whole turn first is what makes the kinds knowable before the first execution —
|
|
1554
|
+
// and it is also what moves the checkpoints, hence the `patched` gate above.
|
|
1555
|
+
const claimed = [];
|
|
1556
|
+
for (const call of turn.toolCalls) {
|
|
1557
|
+
claimed.push(await claimToolCall(turnCalls, call));
|
|
392
1558
|
}
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
input: call.input,
|
|
400
|
-
status: 'pending_approval',
|
|
401
|
-
runId: hooks.runId,
|
|
402
|
-
}));
|
|
403
|
-
const decision = await hooks.awaitApproval(call, ctx);
|
|
404
|
-
if (!decision.approved) {
|
|
405
|
-
await hooks.step(`persist:toolreject:${call.id}`, () => deps.store.updateToolCall({
|
|
406
|
-
toolCallId: call.id,
|
|
407
|
-
status: 'rejected',
|
|
408
|
-
...(decision.reason !== undefined ? { error: decision.reason } : {}),
|
|
409
|
-
}));
|
|
410
|
-
results.push({
|
|
411
|
-
id: call.id,
|
|
412
|
-
name: call.name,
|
|
413
|
-
output: { rejected: true, reason: decision.reason ?? 'rejected by user' },
|
|
414
|
-
error: 'rejected',
|
|
415
|
-
});
|
|
416
|
-
publishAgentToolCall({
|
|
417
|
-
runId: hooks.runId,
|
|
418
|
-
toolName: call.name,
|
|
419
|
-
toolType,
|
|
420
|
-
status: 'rejected',
|
|
421
|
-
});
|
|
422
|
-
continue;
|
|
423
|
-
}
|
|
1559
|
+
// A `skill` load and a `remember` write qualify alongside a `read`: each takes its position on
|
|
1560
|
+
// the call exactly as a read does, and each spends a read's `tool:`/`persist:` names. What
|
|
1561
|
+
// disqualifies the other kinds is not that they have effects — it is that an `action` suspends
|
|
1562
|
+
// on human time and an `agent` delegation is the runtime's own child-workflow fan.
|
|
1563
|
+
if (claimed.every((entry) => entry.toolType === 'read' || entry.toolType === 'skill' || entry.toolType === 'memory')) {
|
|
1564
|
+
results.push(...(await invokeClaimedToolsTogether(turnCalls, parallel, claimed)));
|
|
424
1565
|
}
|
|
425
1566
|
else {
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
toolName: call.name,
|
|
430
|
-
toolType: 'read',
|
|
431
|
-
input: call.input,
|
|
432
|
-
status: 'auto_executed',
|
|
433
|
-
runId: hooks.runId,
|
|
434
|
-
}));
|
|
435
|
-
}
|
|
436
|
-
try {
|
|
437
|
-
// The retry loop runs INSIDE the `tool:<call.id>` step so it stays replay-safe: a durable
|
|
438
|
-
// step memoizes only its successful result, so on replay the whole step returns cached and
|
|
439
|
-
// the retries never re-run (side effects happen exactly once). A classified-transient error
|
|
440
|
-
// (DB deadlock / lock-wait timeout / serialization failure) is retried in place; any other
|
|
441
|
-
// failure surfaces immediately as before. `false` disables retry entirely.
|
|
442
|
-
// Span the tool execution INSIDE the step body (replay-safe). The transient-retry loop runs
|
|
443
|
-
// within the span, so the span covers all in-place attempts; the tool's output never rides it.
|
|
444
|
-
const output = await hooks.step(`tool:${call.id}`, () => spannedAgent('tool.execution', hooks.runId, { runId: hooks.runId, toolCallId: call.id, toolName: call.name, toolType }, () => invokeWithTransientRetry(() => deps.registry.invoke(call.name, call.input, ctx, deps.rolesPolicy), deps.toolTransientRetry ?? {}, {
|
|
445
|
-
...(hooks.isControlFlowError !== undefined
|
|
446
|
-
? { isControlFlowError: hooks.isControlFlowError }
|
|
447
|
-
: {}),
|
|
448
|
-
onRetry: (attempt, retryError) => {
|
|
449
|
-
publishAgentToolRetry({
|
|
450
|
-
runId: hooks.runId,
|
|
451
|
-
toolName: call.name,
|
|
452
|
-
toolCallId: call.id,
|
|
453
|
-
attempt,
|
|
454
|
-
message: retryError instanceof Error ? retryError.message : String(retryError),
|
|
455
|
-
});
|
|
456
|
-
},
|
|
457
|
-
}), () => ({})));
|
|
458
|
-
await hooks.step(`persist:toolexec:${call.id}`, () => deps.store.updateToolCall({
|
|
459
|
-
toolCallId: call.id,
|
|
460
|
-
status: 'executed',
|
|
461
|
-
output,
|
|
462
|
-
...(toolType === 'action' ? { executedByRef: input.actor.id } : {}),
|
|
463
|
-
}));
|
|
464
|
-
results.push({ id: call.id, name: call.name, output });
|
|
465
|
-
publishAgentToolCall({
|
|
466
|
-
runId: hooks.runId,
|
|
467
|
-
toolName: call.name,
|
|
468
|
-
toolType,
|
|
469
|
-
status: 'executed',
|
|
470
|
-
});
|
|
1567
|
+
for (const entry of claimed) {
|
|
1568
|
+
results.push(await runClaimedToolCall(turnCalls, entry));
|
|
1569
|
+
}
|
|
471
1570
|
}
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
results.push(
|
|
476
|
-
publishAgentToolCall({
|
|
477
|
-
runId: hooks.runId,
|
|
478
|
-
toolName: call.name,
|
|
479
|
-
toolType,
|
|
480
|
-
status: 'failed',
|
|
481
|
-
});
|
|
1571
|
+
}
|
|
1572
|
+
else {
|
|
1573
|
+
for (const call of turn.toolCalls) {
|
|
1574
|
+
results.push(await runClaimedToolCall(turnCalls, await claimToolCall(turnCalls, call)));
|
|
482
1575
|
}
|
|
483
1576
|
}
|
|
1577
|
+
// The outputs land on the assistant message that made the calls. A reader reopening the thread
|
|
1578
|
+
// pairs a call with its result off THAT message, so results that only reached the tool-call
|
|
1579
|
+
// table leave every tool in the turn rendering as one still running.
|
|
1580
|
+
//
|
|
1581
|
+
// A position of its own, hence the marker: a run that suspended mid-turn under the shape
|
|
1582
|
+
// without it has no room for a checkpoint between the last tool's persist and the next `llm:`.
|
|
1583
|
+
// Every value written here comes from a checkpoint above, so a replay writes the same list.
|
|
1584
|
+
if (await (hooks.patched?.(MESSAGE_TOOL_RESULTS_PATCH) ?? Promise.resolve(true))) {
|
|
1585
|
+
await hooks.step(`persist:toolresults:${i}`, () => deps.store.setMessageToolResults(assistant.id, [...results, ...syntheticResults]));
|
|
1586
|
+
}
|
|
484
1587
|
modelMessages.push({ role: 'user', content: '', toolResults: results });
|
|
485
1588
|
}
|
|
486
|
-
if (thread
|
|
1589
|
+
if (thread.title === '' || thread.title === 'New chat') {
|
|
487
1590
|
await hooks.step('persist:title', () => deps.store.setTitle(input.threadId, deriveTitle(input.userText)));
|
|
488
1591
|
}
|
|
489
1592
|
// Settle the run `completed` with its rollup. Normal completion only — the loop never records a
|
|
@@ -507,6 +1610,6 @@ export async function runAgentLoop(deps, input, hooks) {
|
|
|
507
1610
|
inputTokens: totalInput,
|
|
508
1611
|
outputTokens: totalOutput,
|
|
509
1612
|
});
|
|
510
|
-
return { text: lastText };
|
|
1613
|
+
return { text: lastText, ...(structured !== undefined ? { object: structured } : {}) };
|
|
511
1614
|
}
|
|
512
1615
|
//# sourceMappingURL=agent-loop.js.map
|