@ouispec/agent-worker 0.1.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/LICENSE +21 -0
- package/README.md +175 -0
- package/dist/approvals/client.d.ts +29 -0
- package/dist/approvals/client.d.ts.map +1 -0
- package/dist/approvals/client.js +50 -0
- package/dist/approvals/client.js.map +1 -0
- package/dist/approvals/continuation.d.ts +28 -0
- package/dist/approvals/continuation.d.ts.map +1 -0
- package/dist/approvals/continuation.js +43 -0
- package/dist/approvals/continuation.js.map +1 -0
- package/dist/approvals/preview.d.ts +15 -0
- package/dist/approvals/preview.d.ts.map +1 -0
- package/dist/approvals/preview.js +33 -0
- package/dist/approvals/preview.js.map +1 -0
- package/dist/approvals/requirement.d.ts +15 -0
- package/dist/approvals/requirement.d.ts.map +1 -0
- package/dist/approvals/requirement.js +26 -0
- package/dist/approvals/requirement.js.map +1 -0
- package/dist/authz/tool-policy.d.ts +63 -0
- package/dist/authz/tool-policy.d.ts.map +1 -0
- package/dist/authz/tool-policy.js +23 -0
- package/dist/authz/tool-policy.js.map +1 -0
- package/dist/container/server.d.ts +24 -0
- package/dist/container/server.d.ts.map +1 -0
- package/dist/container/server.js +119 -0
- package/dist/container/server.js.map +1 -0
- package/dist/emit/http-adapter.d.ts +15 -0
- package/dist/emit/http-adapter.d.ts.map +1 -0
- package/dist/emit/http-adapter.js +35 -0
- package/dist/emit/http-adapter.js.map +1 -0
- package/dist/emit/types.d.ts +9 -0
- package/dist/emit/types.d.ts.map +1 -0
- package/dist/emit/types.js +7 -0
- package/dist/emit/types.js.map +1 -0
- package/dist/events/http-waiter.d.ts +19 -0
- package/dist/events/http-waiter.d.ts.map +1 -0
- package/dist/events/http-waiter.js +20 -0
- package/dist/events/http-waiter.js.map +1 -0
- package/dist/index.d.ts +60 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +45 -0
- package/dist/index.js.map +1 -0
- package/dist/lambda/handler.d.ts +38 -0
- package/dist/lambda/handler.d.ts.map +1 -0
- package/dist/lambda/handler.js +26 -0
- package/dist/lambda/handler.js.map +1 -0
- package/dist/model.d.ts +33 -0
- package/dist/model.d.ts.map +1 -0
- package/dist/model.js +15 -0
- package/dist/model.js.map +1 -0
- package/dist/openapi/document.d.ts +87 -0
- package/dist/openapi/document.d.ts.map +1 -0
- package/dist/openapi/document.js +117 -0
- package/dist/openapi/document.js.map +1 -0
- package/dist/openapi/executor.d.ts +51 -0
- package/dist/openapi/executor.d.ts.map +1 -0
- package/dist/openapi/executor.js +140 -0
- package/dist/openapi/executor.js.map +1 -0
- package/dist/openapi/index.d.ts +14 -0
- package/dist/openapi/index.d.ts.map +1 -0
- package/dist/openapi/index.js +9 -0
- package/dist/openapi/index.js.map +1 -0
- package/dist/openapi/loader.d.ts +81 -0
- package/dist/openapi/loader.d.ts.map +1 -0
- package/dist/openapi/loader.js +361 -0
- package/dist/openapi/loader.js.map +1 -0
- package/dist/openapi/x-agent.d.ts +34 -0
- package/dist/openapi/x-agent.d.ts.map +1 -0
- package/dist/openapi/x-agent.js +102 -0
- package/dist/openapi/x-agent.js.map +1 -0
- package/dist/orchestrator.d.ts +12 -0
- package/dist/orchestrator.d.ts.map +1 -0
- package/dist/orchestrator.js +1019 -0
- package/dist/orchestrator.js.map +1 -0
- package/dist/prompt/builder.d.ts +29 -0
- package/dist/prompt/builder.d.ts.map +1 -0
- package/dist/prompt/builder.js +129 -0
- package/dist/prompt/builder.js.map +1 -0
- package/dist/prompt/index.d.ts +15 -0
- package/dist/prompt/index.d.ts.map +1 -0
- package/dist/prompt/index.js +13 -0
- package/dist/prompt/index.js.map +1 -0
- package/dist/prompt/rules/base.d.ts +6 -0
- package/dist/prompt/rules/base.d.ts.map +1 -0
- package/dist/prompt/rules/base.js +38 -0
- package/dist/prompt/rules/base.js.map +1 -0
- package/dist/prompt/rules/communication.d.ts +7 -0
- package/dist/prompt/rules/communication.d.ts.map +1 -0
- package/dist/prompt/rules/communication.js +53 -0
- package/dist/prompt/rules/communication.js.map +1 -0
- package/dist/prompt/rules/tool-strategy.d.ts +6 -0
- package/dist/prompt/rules/tool-strategy.d.ts.map +1 -0
- package/dist/prompt/rules/tool-strategy.js +42 -0
- package/dist/prompt/rules/tool-strategy.js.map +1 -0
- package/dist/prompt/rules/ui-control.d.ts +10 -0
- package/dist/prompt/rules/ui-control.d.ts.map +1 -0
- package/dist/prompt/rules/ui-control.js +44 -0
- package/dist/prompt/rules/ui-control.js.map +1 -0
- package/dist/prompt/types.d.ts +85 -0
- package/dist/prompt/types.d.ts.map +1 -0
- package/dist/prompt/types.js +10 -0
- package/dist/prompt/types.js.map +1 -0
- package/dist/realtime/long-poll.d.ts +21 -0
- package/dist/realtime/long-poll.d.ts.map +1 -0
- package/dist/realtime/long-poll.js +36 -0
- package/dist/realtime/long-poll.js.map +1 -0
- package/dist/runtime/config.d.ts +4 -0
- package/dist/runtime/config.d.ts.map +1 -0
- package/dist/runtime/config.js +29 -0
- package/dist/runtime/config.js.map +1 -0
- package/dist/runtime/turn-runner.d.ts +32 -0
- package/dist/runtime/turn-runner.d.ts.map +1 -0
- package/dist/runtime/turn-runner.js +216 -0
- package/dist/runtime/turn-runner.js.map +1 -0
- package/dist/runtime/types.d.ts +244 -0
- package/dist/runtime/types.d.ts.map +1 -0
- package/dist/runtime/types.js +2 -0
- package/dist/runtime/types.js.map +1 -0
- package/dist/testing/desk-events.d.ts +12 -0
- package/dist/testing/desk-events.d.ts.map +1 -0
- package/dist/testing/desk-events.js +62 -0
- package/dist/testing/desk-events.js.map +1 -0
- package/dist/testing/index.d.ts +8 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +7 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/testing/openapi-fixture.d.ts +47 -0
- package/dist/testing/openapi-fixture.d.ts.map +1 -0
- package/dist/testing/openapi-fixture.js +370 -0
- package/dist/testing/openapi-fixture.js.map +1 -0
- package/dist/tools/async-binding.d.ts +71 -0
- package/dist/tools/async-binding.d.ts.map +1 -0
- package/dist/tools/async-binding.js +104 -0
- package/dist/tools/async-binding.js.map +1 -0
- package/dist/tools/builtin-tools.d.ts +20 -0
- package/dist/tools/builtin-tools.d.ts.map +1 -0
- package/dist/tools/builtin-tools.js +108 -0
- package/dist/tools/builtin-tools.js.map +1 -0
- package/dist/tools/entity-tools.d.ts +6 -0
- package/dist/tools/entity-tools.d.ts.map +1 -0
- package/dist/tools/entity-tools.js +227 -0
- package/dist/tools/entity-tools.js.map +1 -0
- package/dist/tools/input-validation.d.ts +16 -0
- package/dist/tools/input-validation.d.ts.map +1 -0
- package/dist/tools/input-validation.js +75 -0
- package/dist/tools/input-validation.js.map +1 -0
- package/dist/tools/load-all-tools.d.ts +12 -0
- package/dist/tools/load-all-tools.d.ts.map +1 -0
- package/dist/tools/load-all-tools.js +42 -0
- package/dist/tools/load-all-tools.js.map +1 -0
- package/dist/tools/schema-loader.d.ts +24 -0
- package/dist/tools/schema-loader.d.ts.map +1 -0
- package/dist/tools/schema-loader.js +267 -0
- package/dist/tools/schema-loader.js.map +1 -0
- package/dist/tools/types.d.ts +85 -0
- package/dist/tools/types.d.ts.map +1 -0
- package/dist/tools/types.js +22 -0
- package/dist/tools/types.js.map +1 -0
- package/dist/turn-policy.d.ts +68 -0
- package/dist/turn-policy.d.ts.map +1 -0
- package/dist/turn-policy.js +21 -0
- package/dist/turn-policy.js.map +1 -0
- package/dist/types.d.ts +151 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/ui/channel.d.ts +64 -0
- package/dist/ui/channel.d.ts.map +1 -0
- package/dist/ui/channel.js +81 -0
- package/dist/ui/channel.js.map +1 -0
- package/dist/ui/knowledge.d.ts +30 -0
- package/dist/ui/knowledge.d.ts.map +1 -0
- package/dist/ui/knowledge.js +66 -0
- package/dist/ui/knowledge.js.map +1 -0
- package/dist/ui/observations.d.ts +41 -0
- package/dist/ui/observations.d.ts.map +1 -0
- package/dist/ui/observations.js +236 -0
- package/dist/ui/observations.js.map +1 -0
- package/dist/ui/snapshot.d.ts +33 -0
- package/dist/ui/snapshot.d.ts.map +1 -0
- package/dist/ui/snapshot.js +75 -0
- package/dist/ui/snapshot.js.map +1 -0
- package/dist/ui/ui-sequence.d.ts +75 -0
- package/dist/ui/ui-sequence.d.ts.map +1 -0
- package/dist/ui/ui-sequence.js +73 -0
- package/dist/ui/ui-sequence.js.map +1 -0
- package/dist/ui/ui-tools.d.ts +59 -0
- package/dist/ui/ui-tools.d.ts.map +1 -0
- package/dist/ui/ui-tools.js +244 -0
- package/dist/ui/ui-tools.js.map +1 -0
- package/package.json +78 -0
|
@@ -0,0 +1,1019 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent Orchestrator — powered by Vercel AI SDK.
|
|
3
|
+
*
|
|
4
|
+
* The model is the host's: any `ai` library model, passed in configuration
|
|
5
|
+
* (ADR-0227 §2.2, "Model"). The core names no provider. The AI SDK handles:
|
|
6
|
+
* - each provider's wire format (internal, never exposed)
|
|
7
|
+
* - tool_use / tool_result pairing (automatic, no manual repair needed)
|
|
8
|
+
* - Multi-round tool loops (via stopWhen)
|
|
9
|
+
* - True token streaming with coalescing
|
|
10
|
+
* - Step callbacks for logging
|
|
11
|
+
* - Error categorization and retries
|
|
12
|
+
*
|
|
13
|
+
* Our value-add stays:
|
|
14
|
+
* - UI tools from the client's OUI surfaces, answered by the client (ADR-0209)
|
|
15
|
+
* - Schema-based tool loading
|
|
16
|
+
* - Persona/prompt compilation
|
|
17
|
+
* - Emit adapter (realtime socket streaming)
|
|
18
|
+
* - ApiSurface binding for tool execution
|
|
19
|
+
* - Configurable TurnPolicy for product-specific step control
|
|
20
|
+
*/
|
|
21
|
+
import { streamText, dynamicTool, jsonSchema, isStepCount, hasToolCall } from 'ai';
|
|
22
|
+
import { AGENT_SOCKET_EVENTS, argsHash } from '@ouispec/agent-core';
|
|
23
|
+
import { defaultTurnPolicy } from './turn-policy.js';
|
|
24
|
+
import { evaluateToolPolicySafe } from './authz/tool-policy.js';
|
|
25
|
+
import { createToolInputValidator } from './tools/input-validation.js';
|
|
26
|
+
import { readClientSnapshot, capabilityFingerprint, withoutClientUI } from './ui/snapshot.js';
|
|
27
|
+
import { readClientKnowledge, withClientKnowledge } from './ui/knowledge.js';
|
|
28
|
+
import { buildUITools } from './ui/ui-tools.js';
|
|
29
|
+
import { createUISequence } from './ui/ui-sequence.js';
|
|
30
|
+
import { observationsText } from './ui/observations.js';
|
|
31
|
+
import { approvalRequirement, APPROVAL_TOOL_NOTE } from './approvals/requirement.js';
|
|
32
|
+
import { buildApprovalPreview, declaredTitle } from './approvals/preview.js';
|
|
33
|
+
import { approvalNote, ranNote, resolveContinuation, unavailableNote } from './approvals/continuation.js';
|
|
34
|
+
const DEFAULT_MAX_ROUNDS = 12;
|
|
35
|
+
const DEFAULT_MAX_TOKENS = 4096;
|
|
36
|
+
const DEFAULT_TEMPERATURE = 0.3;
|
|
37
|
+
const DEFAULT_TOOL_TIMEOUT_MS = 30_000;
|
|
38
|
+
const DEFAULT_UI_RESULT_TIMEOUT_MS = 20_000;
|
|
39
|
+
/** Time left, before the turn's deadline, for the model to answer after waiting on a UI action's work. */
|
|
40
|
+
const UI_ANSWER_MARGIN_MS = 45_000;
|
|
41
|
+
const DEFAULT_PAGE_STATE_CHARS = 6_000;
|
|
42
|
+
/**
|
|
43
|
+
* Tools that hand the turn back to the user. The turn stops after any of them:
|
|
44
|
+
* the model must not carry on as if the user had already answered.
|
|
45
|
+
*/
|
|
46
|
+
const USER_INPUT_TOOLS = ['present_options', 'confirm_action', 'request_user_decision'];
|
|
47
|
+
function log(level, ns, message, data) {
|
|
48
|
+
const entry = { level, ns, message, timestamp: new Date().toISOString(), ...data };
|
|
49
|
+
if (level === 'error')
|
|
50
|
+
console.error(JSON.stringify(entry));
|
|
51
|
+
else if (level === 'warn')
|
|
52
|
+
console.warn(JSON.stringify(entry));
|
|
53
|
+
else
|
|
54
|
+
console.log(JSON.stringify(entry));
|
|
55
|
+
}
|
|
56
|
+
/** A call refused before its tool ran. */
|
|
57
|
+
const notRun = (payload) => ({ text: JSON.stringify(payload), ran: false });
|
|
58
|
+
/**
|
|
59
|
+
* Run a complete agent turn using the Vercel AI SDK.
|
|
60
|
+
*
|
|
61
|
+
* A turn runs in one or more segments. Each segment is one `streamText` call
|
|
62
|
+
* with a fixed tool set. When a UI action changes what the client's page
|
|
63
|
+
* offers (navigating is the common case), the segment ends and the turn
|
|
64
|
+
* continues in a new one whose tools are the new page's (ADR-0209 D3). The
|
|
65
|
+
* round cap counts across segments.
|
|
66
|
+
*/
|
|
67
|
+
export async function runAgentTurn(config, input) {
|
|
68
|
+
const maxRounds = config.maxToolRounds ?? DEFAULT_MAX_ROUNDS;
|
|
69
|
+
const maxTokens = config.maxTokens ?? DEFAULT_MAX_TOKENS;
|
|
70
|
+
const temperature = config.temperature ?? DEFAULT_TEMPERATURE;
|
|
71
|
+
const toolTimeoutMs = config.toolTimeoutMs ?? DEFAULT_TOOL_TIMEOUT_MS;
|
|
72
|
+
const turnDeadlineMs = config.turnDeadlineMs ?? 240_000; // 4 min, below the 5 min Lambda ceiling
|
|
73
|
+
const retries = config.retries ?? 3;
|
|
74
|
+
const uiResultTimeoutMs = config.ui?.resultTimeoutMs ?? DEFAULT_UI_RESULT_TIMEOUT_MS;
|
|
75
|
+
if (!config.model) {
|
|
76
|
+
throw new Error('[agent-sdk] config.model is required: pass an `ai` library model (ADR-0227 §2.2)');
|
|
77
|
+
}
|
|
78
|
+
const { turnId, socketRoom } = input;
|
|
79
|
+
log('info', 'agent:turn', 'Turn started', {
|
|
80
|
+
turnId,
|
|
81
|
+
conversationId: input.conversationId,
|
|
82
|
+
userId: input.userId,
|
|
83
|
+
historyLength: input.history?.length ?? 0,
|
|
84
|
+
contentLength: input.content.length,
|
|
85
|
+
maxRounds,
|
|
86
|
+
maxTokens,
|
|
87
|
+
temperature,
|
|
88
|
+
});
|
|
89
|
+
// ─── The client's UI (ADR-0209 D1) ─────────────────────────────────────────
|
|
90
|
+
// The turn carries the surfaces of the client that sent it. They are the only
|
|
91
|
+
// source of UI tools, so the tools always describe the page the user is on.
|
|
92
|
+
const snapshot = readClientSnapshot(input.context ?? null);
|
|
93
|
+
if (snapshot && !config.ui) {
|
|
94
|
+
throw new Error('[agent-sdk] The turn carries UI surfaces but the worker has no UI action channel (config.ui): ' +
|
|
95
|
+
'its UI actions could be sent but never answered.');
|
|
96
|
+
}
|
|
97
|
+
let currentSurfaces = snapshot?.surfaces ?? [];
|
|
98
|
+
// The turn's UI actions run one at a time, in the order the model called them,
|
|
99
|
+
// across every rebuild of the UI tools (ui-sequence.ts).
|
|
100
|
+
const uiSequence = createUISequence();
|
|
101
|
+
// The page's surfaces as the turn's snapshot gave them, under the client's hash for them.
|
|
102
|
+
if (snapshot)
|
|
103
|
+
uiSequence.record(snapshot.surfaces, snapshot.surfacesHash);
|
|
104
|
+
// Set when an answer shows the page now offers different actions; the
|
|
105
|
+
// current segment then ends and the next is built from these.
|
|
106
|
+
let pendingSurfaces = null;
|
|
107
|
+
// The knowledge the client sent for its page goes after the host's prompt.
|
|
108
|
+
const knowledge = readClientKnowledge(input.context ?? null);
|
|
109
|
+
log('info', 'agent:ui', 'UI surfaces for this turn', {
|
|
110
|
+
turnId,
|
|
111
|
+
clientSentSnapshot: snapshot !== null,
|
|
112
|
+
surfaceIds: currentSurfaces.map((s) => s.id),
|
|
113
|
+
actionCount: currentSurfaces.reduce((n, s) => n + s.actions.length, 0),
|
|
114
|
+
knowledgeEntries: knowledge?.entries.length ?? 0,
|
|
115
|
+
knowledgeWorkflows: knowledge?.workflows.length ?? 0,
|
|
116
|
+
});
|
|
117
|
+
// ─── Turn classification via policy ────────────────────────────────────────
|
|
118
|
+
const turnPolicy = config.turnPolicy ?? defaultTurnPolicy;
|
|
119
|
+
const historyForClassify = input.history?.map((m) => ({
|
|
120
|
+
role: m.role,
|
|
121
|
+
content: 'content' in m ? (m.content ?? '') : '',
|
|
122
|
+
})) ?? [];
|
|
123
|
+
const turnClass = turnPolicy.classifyTurn(input.content, historyForClassify);
|
|
124
|
+
// Let the policy perform side effects (e.g. deterministic navigation emit)
|
|
125
|
+
if (turnPolicy.onTurnClassified) {
|
|
126
|
+
await turnPolicy.onTurnClassified({
|
|
127
|
+
turnClass,
|
|
128
|
+
content: input.content,
|
|
129
|
+
emit: config.emit,
|
|
130
|
+
socketRoom,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
log('info', 'agent:turn', 'Turn classified', { turnId, turnClass });
|
|
134
|
+
// Build system prompt: the host's, which never sees what the client sent for
|
|
135
|
+
// the worker itself, then the client's knowledge.
|
|
136
|
+
const hostPrompt = typeof config.systemPrompt === 'function'
|
|
137
|
+
? config.systemPrompt({ userId: input.userId, accountId: input.accountId, context: withoutClientUI(input.context) })
|
|
138
|
+
: config.systemPrompt;
|
|
139
|
+
const systemPrompt = withClientKnowledge(hostPrompt, input.context);
|
|
140
|
+
log('debug', 'agent:prompt', 'System prompt built', { turnId, promptLength: systemPrompt.length });
|
|
141
|
+
// Wall-clock deadline: abort the entire streamText call if it exceeds turnDeadlineMs.
|
|
142
|
+
const turnStartedAt = Date.now();
|
|
143
|
+
// A UI tool waiting on work it started stops in time for the model to answer.
|
|
144
|
+
const uiWaitDeadline = () => turnStartedAt + turnDeadlineMs - UI_ANSWER_MARGIN_MS;
|
|
145
|
+
const abortController = new AbortController();
|
|
146
|
+
const deadlineTimer = setTimeout(() => abortController.abort('Turn deadline exceeded'), turnDeadlineMs);
|
|
147
|
+
// Build tool execution context
|
|
148
|
+
const toolCtx = {
|
|
149
|
+
userId: input.userId,
|
|
150
|
+
accountId: input.accountId,
|
|
151
|
+
turnId,
|
|
152
|
+
conversationId: input.conversationId,
|
|
153
|
+
userToken: input.userToken,
|
|
154
|
+
apiSurface: config.apiSurface,
|
|
155
|
+
abortSignal: abortController.signal,
|
|
156
|
+
socketRoom,
|
|
157
|
+
};
|
|
158
|
+
// Track tool executions for emit events and persistence.
|
|
159
|
+
// We capture toolCallId from the execute options (2nd argument).
|
|
160
|
+
const executedToolResults = [];
|
|
161
|
+
// Per-tool invocation quotas — mandatory for side-effecting tools.
|
|
162
|
+
// Quota is per-turn, held in this closure — never module scope. It spans
|
|
163
|
+
// segments: rebuilding the tool set does not reset what was already used.
|
|
164
|
+
const toolInvocationCounts = new Map();
|
|
165
|
+
const DEFAULT_TOOL_QUOTA = 12; // matches maxRounds
|
|
166
|
+
const SIDE_EFFECT_TOOL_QUOTA = 2;
|
|
167
|
+
// ─── Approvals (ADR-0228) ──────────────────────────────────────────────────
|
|
168
|
+
// The first call in a turn that needs the user's approval stops the turn:
|
|
169
|
+
// it is stored as a pending approval, the card is shown, and nothing else
|
|
170
|
+
// runs until they decide. Each call is approved separately.
|
|
171
|
+
let approvalHold = null;
|
|
172
|
+
// jsonSchema() carries no validator, so a call's arguments are whatever the
|
|
173
|
+
// model produced. Each tool's declared schema is compiled once per turn.
|
|
174
|
+
const validators = new WeakMap();
|
|
175
|
+
const validatorFor = (t) => {
|
|
176
|
+
let v = validators.get(t);
|
|
177
|
+
if (!v) {
|
|
178
|
+
// The schema the tool DECLARED, not the model-facing stand-in: a strict
|
|
179
|
+
// empty schema only gives the model a valid schema for a tool that
|
|
180
|
+
// declared no properties, and enforcing it would reject input such a
|
|
181
|
+
// tool has always received.
|
|
182
|
+
v = createToolInputValidator(t.inputSchema);
|
|
183
|
+
validators.set(t, v);
|
|
184
|
+
}
|
|
185
|
+
return v;
|
|
186
|
+
};
|
|
187
|
+
/**
|
|
188
|
+
* Runs one call of a tool: validation, quota, policy, approval, then the
|
|
189
|
+
* tool. `approved` is set only for the call a redeemed approval token names,
|
|
190
|
+
* in the turn after the user's decision.
|
|
191
|
+
*/
|
|
192
|
+
const executeCall = async (t, rawArgs, toolUseId, uiSlot, approved) => {
|
|
193
|
+
const startMs = Date.now();
|
|
194
|
+
const isUI = t.kind === 'ui';
|
|
195
|
+
// A UI tool waits for the client's answer, which has its own deadline, and
|
|
196
|
+
// then for the outcome of work it started, until uiWaitDeadline.
|
|
197
|
+
const timeoutMs = isUI
|
|
198
|
+
? Math.max(toolTimeoutMs, uiResultTimeoutMs + 5_000, uiWaitDeadline() - Date.now() + uiResultTimeoutMs + 5_000)
|
|
199
|
+
: toolTimeoutMs;
|
|
200
|
+
// ── Input validation ──
|
|
201
|
+
// Before quota and policy: an invalid call consumes no quota and never
|
|
202
|
+
// reaches the host. The model gets the errors so it can correct the call.
|
|
203
|
+
const validation = validatorFor(t)(rawArgs);
|
|
204
|
+
if (!validation.ok) {
|
|
205
|
+
log('warn', 'agent:tool', 'Tool call rejected: invalid input', {
|
|
206
|
+
turnId,
|
|
207
|
+
toolName: t.name,
|
|
208
|
+
errors: validation.errors,
|
|
209
|
+
});
|
|
210
|
+
return notRun({
|
|
211
|
+
success: false,
|
|
212
|
+
error: `Invalid input for "${t.name}": ${validation.errors.join('; ')}`,
|
|
213
|
+
invalidInput: true,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
const args = validation.value;
|
|
217
|
+
// ── Quota enforcement ──
|
|
218
|
+
// UI actions run as the user in their own session (ADR-0182 §3), so the
|
|
219
|
+
// backend side-effect cap does not apply to them (ADR-0209 D5).
|
|
220
|
+
const currentCount = toolInvocationCounts.get(t.name) ?? 0;
|
|
221
|
+
const isSideEffecting = t.inputSchema?.sideEffects !== false; // fail-closed: undefined = side-effecting
|
|
222
|
+
const quota = !isUI && isSideEffecting ? SIDE_EFFECT_TOOL_QUOTA : DEFAULT_TOOL_QUOTA;
|
|
223
|
+
if (currentCount >= quota) {
|
|
224
|
+
return notRun({
|
|
225
|
+
success: false,
|
|
226
|
+
error: `Tool "${t.name}" has reached its maximum invocation quota of ${quota} for this turn. Please proceed without calling it again.`,
|
|
227
|
+
quotaExceeded: true,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
toolInvocationCounts.set(t.name, currentCount + 1);
|
|
231
|
+
// ── Policy enforcement ──
|
|
232
|
+
// A deny always wins, over an approval too (ADR-0228 §2.1).
|
|
233
|
+
const requirement = approvalRequirement(t);
|
|
234
|
+
let policyRequiresApproval = false;
|
|
235
|
+
if (config.toolPolicy) {
|
|
236
|
+
const decision = await evaluateToolPolicySafe(config.toolPolicy, {
|
|
237
|
+
userId: input.userId,
|
|
238
|
+
accountId: input.accountId,
|
|
239
|
+
toolName: t.name,
|
|
240
|
+
args,
|
|
241
|
+
turnId,
|
|
242
|
+
hasSideEffects: isSideEffecting,
|
|
243
|
+
toolKind: isUI ? 'ui' : 'backend',
|
|
244
|
+
effect: requirement.effect,
|
|
245
|
+
destructive: requirement.destructive,
|
|
246
|
+
});
|
|
247
|
+
if (decision.action === 'deny') {
|
|
248
|
+
return notRun({
|
|
249
|
+
success: false,
|
|
250
|
+
error: decision.reason,
|
|
251
|
+
policyDenied: true,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
policyRequiresApproval = decision.action === 'require_approval';
|
|
255
|
+
}
|
|
256
|
+
// ── Approval (ADR-0228) ──
|
|
257
|
+
// Checked and set with no await in between, so of the calls in one
|
|
258
|
+
// response exactly the first that needs approval stops the turn.
|
|
259
|
+
if (approvalHold && !approved) {
|
|
260
|
+
return notRun({
|
|
261
|
+
success: false,
|
|
262
|
+
notRun: true,
|
|
263
|
+
error: `Not run: this turn is waiting for the user's approval of "${approvalHold.title}". ` +
|
|
264
|
+
'Nothing else runs until they decide; ask again afterwards if it is still needed.',
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
if ((requirement.required || policyRequiresApproval) && !approved) {
|
|
268
|
+
return { text: await requestApproval(t, args, toolUseId, requirement), ran: false };
|
|
269
|
+
}
|
|
270
|
+
log('info', 'agent:tool', 'Tool call started', {
|
|
271
|
+
turnId,
|
|
272
|
+
toolName: t.name,
|
|
273
|
+
kind: t.kind ?? 'backend',
|
|
274
|
+
input: args,
|
|
275
|
+
...(approved ? { approvalId: approved.approvalId } : {}),
|
|
276
|
+
});
|
|
277
|
+
// Emit tool call started BEFORE execution
|
|
278
|
+
await config.emit.emit(socketRoom, AGENT_SOCKET_EVENTS.TOOL_CALL_STARTED, {
|
|
279
|
+
turnId,
|
|
280
|
+
toolUseId,
|
|
281
|
+
name: t.name,
|
|
282
|
+
input: args,
|
|
283
|
+
timestamp: startMs,
|
|
284
|
+
});
|
|
285
|
+
// Execute with timeout
|
|
286
|
+
let result;
|
|
287
|
+
let timer;
|
|
288
|
+
try {
|
|
289
|
+
result = await Promise.race([
|
|
290
|
+
t.execute(args, {
|
|
291
|
+
...toolCtx,
|
|
292
|
+
toolCallId: toolUseId,
|
|
293
|
+
...(uiSlot ? { uiSlot } : {}),
|
|
294
|
+
...(approved ? { approval: approved } : {}),
|
|
295
|
+
}),
|
|
296
|
+
new Promise((_, reject) => {
|
|
297
|
+
timer = setTimeout(() => reject(new Error(`Tool ${t.name} timed out after ${timeoutMs}ms`)), timeoutMs);
|
|
298
|
+
}),
|
|
299
|
+
]);
|
|
300
|
+
}
|
|
301
|
+
catch (err) {
|
|
302
|
+
result = { success: false, error: err instanceof Error ? err.message : String(err) };
|
|
303
|
+
}
|
|
304
|
+
finally {
|
|
305
|
+
if (timer)
|
|
306
|
+
clearTimeout(timer);
|
|
307
|
+
}
|
|
308
|
+
const durationMs = Date.now() - startMs;
|
|
309
|
+
// Normalize result — built-in tools (present_options, etc.) return the
|
|
310
|
+
// raw payload directly, while schema and UI tools return { success, data, error }.
|
|
311
|
+
const isWrapped = result && typeof result === 'object' && 'success' in result;
|
|
312
|
+
const resultData = isWrapped ? (result.data ?? result.error ?? null) : result;
|
|
313
|
+
const resultSuccess = isWrapped ? result.success : true;
|
|
314
|
+
// A failed call that still carries data (a UI action's page state) must
|
|
315
|
+
// not lose its error message on the way to the model.
|
|
316
|
+
const modelPayload = isWrapped && !result.success && result.data !== undefined && result.error
|
|
317
|
+
? { error: result.error, ...result.data }
|
|
318
|
+
: resultData;
|
|
319
|
+
log('info', 'agent:tool', 'Tool call completed', {
|
|
320
|
+
turnId,
|
|
321
|
+
toolName: t.name,
|
|
322
|
+
success: resultSuccess,
|
|
323
|
+
durationMs,
|
|
324
|
+
...(isUI && !resultSuccess ? { error: isWrapped ? result.error : undefined } : {}),
|
|
325
|
+
});
|
|
326
|
+
await config.emit.emit(socketRoom, AGENT_SOCKET_EVENTS.TOOL_CALL_COMPLETE, {
|
|
327
|
+
turnId,
|
|
328
|
+
toolUseId,
|
|
329
|
+
name: t.name,
|
|
330
|
+
result: modelPayload,
|
|
331
|
+
success: resultSuccess,
|
|
332
|
+
durationMs,
|
|
333
|
+
timestamp: Date.now(),
|
|
334
|
+
});
|
|
335
|
+
return { text: JSON.stringify(modelPayload ?? { error: 'no result' }), ran: true };
|
|
336
|
+
};
|
|
337
|
+
/**
|
|
338
|
+
* Stops the turn at a call that needs the user's approval (ADR-0228 §2.2.1):
|
|
339
|
+
* stores it as a pending approval, shows the card, and tells the model only
|
|
340
|
+
* that it is waiting. The model never sees a token.
|
|
341
|
+
*/
|
|
342
|
+
const requestApproval = async (t, args, toolUseId, requirement) => {
|
|
343
|
+
const preview = buildApprovalPreview(t, args);
|
|
344
|
+
approvalHold = { approvalId: toolUseId, title: preview.title };
|
|
345
|
+
const refuse = (why) => {
|
|
346
|
+
approvalHold = null;
|
|
347
|
+
log('error', 'agent:tool', 'Approval could not be requested; the call was not run', { turnId, toolName: t.name, why });
|
|
348
|
+
return JSON.stringify({
|
|
349
|
+
success: false,
|
|
350
|
+
notRun: true,
|
|
351
|
+
error: `"${preview.title}" needs the user's approval, which could not be asked for (${why}). It was not run.`,
|
|
352
|
+
});
|
|
353
|
+
};
|
|
354
|
+
if (!config.approvals)
|
|
355
|
+
return refuse('this assistant has no approval store');
|
|
356
|
+
const expiresAt = Date.now() + requirement.ttlMs;
|
|
357
|
+
try {
|
|
358
|
+
const hash = await argsHash(args);
|
|
359
|
+
await config.approvals.create({
|
|
360
|
+
approvalId: toolUseId,
|
|
361
|
+
toolCallId: toolUseId,
|
|
362
|
+
conversationId: input.conversationId,
|
|
363
|
+
turnId,
|
|
364
|
+
userId: input.userId,
|
|
365
|
+
tool: t.name,
|
|
366
|
+
args,
|
|
367
|
+
argsHash: hash,
|
|
368
|
+
effect: requirement.effectName,
|
|
369
|
+
destructive: requirement.destructive,
|
|
370
|
+
argsSensitive: t.argsSensitive !== false,
|
|
371
|
+
expiresAt,
|
|
372
|
+
preview,
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
catch (err) {
|
|
376
|
+
return refuse(err instanceof Error ? err.message : String(err));
|
|
377
|
+
}
|
|
378
|
+
await config.emit.emit(socketRoom, AGENT_SOCKET_EVENTS.APPROVAL_REQUIRED, {
|
|
379
|
+
turnId,
|
|
380
|
+
conversationId: input.conversationId,
|
|
381
|
+
approvalId: toolUseId,
|
|
382
|
+
tool: t.name,
|
|
383
|
+
effect: requirement.effectName,
|
|
384
|
+
destructive: requirement.destructive,
|
|
385
|
+
preview,
|
|
386
|
+
expiresAt,
|
|
387
|
+
timestamp: Date.now(),
|
|
388
|
+
});
|
|
389
|
+
log('info', 'agent:tool', 'Turn stopped for the user’s approval', {
|
|
390
|
+
turnId,
|
|
391
|
+
toolName: t.name,
|
|
392
|
+
approvalId: toolUseId,
|
|
393
|
+
effect: requirement.effectName,
|
|
394
|
+
destructive: requirement.destructive,
|
|
395
|
+
});
|
|
396
|
+
return JSON.stringify({
|
|
397
|
+
success: false,
|
|
398
|
+
awaitingApproval: true,
|
|
399
|
+
approvalId: toolUseId,
|
|
400
|
+
message: `Waiting for the user to approve "${preview.title}" on the approval card. It has not run. ` +
|
|
401
|
+
'The turn ends here: it runs only if they approve it there, and the next turn tells you the outcome.',
|
|
402
|
+
});
|
|
403
|
+
};
|
|
404
|
+
// Convert a RegisteredTool to an AI SDK tool. Our tools have runtime-defined
|
|
405
|
+
// JSON Schemas — dynamicTool accepts raw JSON Schema via jsonSchema(), which
|
|
406
|
+
// is exactly our use case.
|
|
407
|
+
function toAiTool(t) {
|
|
408
|
+
const rawSchema = t.inputSchema && Object.keys(t.inputSchema.properties ?? {}).length > 0
|
|
409
|
+
? t.inputSchema
|
|
410
|
+
: { type: 'object', properties: {}, additionalProperties: false };
|
|
411
|
+
const isUI = t.kind === 'ui';
|
|
412
|
+
// A UI call takes its place in the turn's UI order synchronously, as the
|
|
413
|
+
// model's call arrives: the checks and the emit below await, and calls from
|
|
414
|
+
// one response may get through them in any order (ui-sequence.ts). The place
|
|
415
|
+
// is given up if the call never reaches the page, and otherwise held until
|
|
416
|
+
// its action settles.
|
|
417
|
+
const execute = async (rawArgs, options) => {
|
|
418
|
+
const toolUseId = options?.toolCallId ?? `tool_${Date.now()}`;
|
|
419
|
+
const uiSlot = isUI ? uiSequence.reserve() : undefined;
|
|
420
|
+
const { text } = await executeCall(t, rawArgs, toolUseId, uiSlot).finally(() => uiSlot?.release());
|
|
421
|
+
executedToolResults.push({ toolCallId: toolUseId, toolName: t.name, result: text });
|
|
422
|
+
return text;
|
|
423
|
+
};
|
|
424
|
+
// A call that needs approval says so in its description, in place of any
|
|
425
|
+
// "confirm first" instruction: the card is the confirmation.
|
|
426
|
+
const description = approvalRequirement(t).required ? `${t.description}\n\n${APPROVAL_TOOL_NOTE}` : t.description;
|
|
427
|
+
return dynamicTool({
|
|
428
|
+
description,
|
|
429
|
+
inputSchema: jsonSchema(rawSchema),
|
|
430
|
+
execute,
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
// The tools for the current surfaces: the host's tools plus one UI tool per
|
|
434
|
+
// mounted action. A UI action keeps its name over a host tool of the same
|
|
435
|
+
// name (ADR-0209 D6): for a UI-only agent, the page is the authority.
|
|
436
|
+
function currentTools() {
|
|
437
|
+
const hostTools = config.tools.tools;
|
|
438
|
+
let uiTools = [];
|
|
439
|
+
if (config.ui && currentSurfaces.length > 0) {
|
|
440
|
+
const built = buildUITools(currentSurfaces, {
|
|
441
|
+
channel: config.ui.channel,
|
|
442
|
+
resultTimeoutMs: uiResultTimeoutMs,
|
|
443
|
+
maxObservationChars: config.ui.maxObservationChars,
|
|
444
|
+
waitDeadline: uiWaitDeadline,
|
|
445
|
+
currentSurfaces: () => currentSurfaces,
|
|
446
|
+
sequence: uiSequence,
|
|
447
|
+
onResult: (answer) => {
|
|
448
|
+
if (!answer.surfaces)
|
|
449
|
+
return;
|
|
450
|
+
if (capabilityFingerprint(answer.surfaces) !== capabilityFingerprint(currentSurfaces)) {
|
|
451
|
+
pendingSurfaces = answer.surfaces;
|
|
452
|
+
}
|
|
453
|
+
},
|
|
454
|
+
});
|
|
455
|
+
uiTools = built.tools;
|
|
456
|
+
for (const c of built.collisions) {
|
|
457
|
+
log('error', 'agent:ui', 'Two mounted surfaces declare the same action id; the first keeps it', {
|
|
458
|
+
turnId,
|
|
459
|
+
...c,
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
const uiNames = new Set(uiTools.map((t) => t.name));
|
|
464
|
+
const withheld = hostTools.filter((t) => uiNames.has(t.name)).map((t) => t.name);
|
|
465
|
+
if (withheld.length > 0) {
|
|
466
|
+
log('error', 'agent:ui', 'UI action ids collide with host tools; the host tools are withheld this turn', {
|
|
467
|
+
turnId,
|
|
468
|
+
withheld,
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
return [...hostTools.filter((t) => !uiNames.has(t.name)), ...uiTools];
|
|
472
|
+
}
|
|
473
|
+
function assembleTools() {
|
|
474
|
+
const tools = {};
|
|
475
|
+
for (const t of currentTools())
|
|
476
|
+
tools[t.name] = toAiTool(t);
|
|
477
|
+
return tools;
|
|
478
|
+
}
|
|
479
|
+
/**
|
|
480
|
+
* The turn after the user's decision on an approval card (ADR-0228 §2.2.5):
|
|
481
|
+
* redeem the token and run exactly the stored call, or learn that they
|
|
482
|
+
* declined. What happened goes to the model as an `<approval>` note, and a
|
|
483
|
+
* call that was run as a tool call and its result, which are persisted too.
|
|
484
|
+
*/
|
|
485
|
+
async function continueApproval(continuation) {
|
|
486
|
+
const outcome = await resolveContinuation(config.approvals, continuation, {
|
|
487
|
+
userId: input.userId,
|
|
488
|
+
conversationId: input.conversationId,
|
|
489
|
+
});
|
|
490
|
+
if (outcome.kind === 'note') {
|
|
491
|
+
log('info', 'agent:tool', 'Approval continuation ran nothing', { turnId, approvalId: continuation.approvalId, decision: continuation.decision });
|
|
492
|
+
return { note: outcome.note, messages: [], persisted: [] };
|
|
493
|
+
}
|
|
494
|
+
const { call } = outcome;
|
|
495
|
+
const tool = currentTools().find((t) => t.name === call.tool);
|
|
496
|
+
if (!tool) {
|
|
497
|
+
log('warn', 'agent:tool', 'Approved call is not available this turn; it was not run', { turnId, approvalId: call.approvalId, toolName: call.tool });
|
|
498
|
+
return { note: unavailableNote(call.tool), messages: [], persisted: [] };
|
|
499
|
+
}
|
|
500
|
+
const uiSlot = tool.kind === 'ui' ? uiSequence.reserve() : undefined;
|
|
501
|
+
const { text, ran } = await executeCall(tool, call.args, call.approvalId, uiSlot, {
|
|
502
|
+
approvalId: call.approvalId,
|
|
503
|
+
argsHash: call.argsHash,
|
|
504
|
+
}).finally(() => uiSlot?.release());
|
|
505
|
+
// An approved action that changed the page: the turn starts on the new one.
|
|
506
|
+
if (pendingSurfaces) {
|
|
507
|
+
currentSurfaces = pendingSurfaces;
|
|
508
|
+
pendingSurfaces = null;
|
|
509
|
+
}
|
|
510
|
+
const { title } = declaredTitle(tool);
|
|
511
|
+
const callId = `${call.approvalId}-approved`;
|
|
512
|
+
const refusal = ran ? null : JSON.parse(text).error ?? 'it was refused';
|
|
513
|
+
return {
|
|
514
|
+
note: ran ? ranNote(title) : approvalNote(`The user approved "${title}" on the approval card, but it did not run: ${refusal}`),
|
|
515
|
+
messages: [
|
|
516
|
+
{ role: 'assistant', content: [{ type: 'tool-call', toolCallId: callId, toolName: tool.name, input: call.args }] },
|
|
517
|
+
{ role: 'tool', content: [{ type: 'tool-result', toolCallId: callId, toolName: tool.name, output: { type: 'text', value: text } }] },
|
|
518
|
+
],
|
|
519
|
+
persisted: [
|
|
520
|
+
{ role: 'assistant', content: null, toolCalls: [{ id: callId, name: tool.name, arguments: call.args }] },
|
|
521
|
+
{ role: 'tool', content: text, toolCallId: callId, name: tool.name },
|
|
522
|
+
],
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
// Track cumulative text length emitted this turn.
|
|
526
|
+
let totalTextEmitted = 0;
|
|
527
|
+
// ─── Coalesce token deltas ─────────────────────────────────────────────────
|
|
528
|
+
// Buffer for 50ms or 32 chars, whichever comes first, to avoid one HTTP POST
|
|
529
|
+
// per token delta to the realtime server.
|
|
530
|
+
const COALESCE_MS = 50;
|
|
531
|
+
const COALESCE_CHARS = 32;
|
|
532
|
+
let tokenBuffer = '';
|
|
533
|
+
let flushTimer = null;
|
|
534
|
+
const flushTokenBuffer = async () => {
|
|
535
|
+
if (flushTimer) {
|
|
536
|
+
clearTimeout(flushTimer);
|
|
537
|
+
flushTimer = null;
|
|
538
|
+
}
|
|
539
|
+
if (tokenBuffer.length > 0) {
|
|
540
|
+
const text = tokenBuffer;
|
|
541
|
+
tokenBuffer = '';
|
|
542
|
+
totalTextEmitted += text.length;
|
|
543
|
+
await config.emit.emit(socketRoom, AGENT_SOCKET_EVENTS.TOKEN, {
|
|
544
|
+
turnId,
|
|
545
|
+
text,
|
|
546
|
+
timestamp: Date.now(),
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
};
|
|
550
|
+
// Run the multi-round generation
|
|
551
|
+
try {
|
|
552
|
+
// ─── Prompt cache breakpoints ──────────────────────────────────────────────
|
|
553
|
+
// Every round of every turn re-sent the same ~25k-token prefix at full price:
|
|
554
|
+
// the system prompt plus ~87 tool schemas, unchanged from the first call to
|
|
555
|
+
// the last. A five-round turn paid for it five times.
|
|
556
|
+
//
|
|
557
|
+
// Providers that cache by breakpoint (Bedrock, Anthropic) render the request
|
|
558
|
+
// as tools -> system -> messages, and a breakpoint caches the whole prefix up
|
|
559
|
+
// to itself. So a breakpoint at the end of the system block covers the tool
|
|
560
|
+
// definitions too, even where the provider offers no way to mark tools.
|
|
561
|
+
//
|
|
562
|
+
// What marks a breakpoint is provider-specific, so it is the host's to say
|
|
563
|
+
// (`promptCacheBreakpoint`, e.g. PROMPT_CACHE_BREAKPOINTS.bedrock). Without
|
|
564
|
+
// one, nothing is marked: a provider that caches on its own still does.
|
|
565
|
+
//
|
|
566
|
+
// The system prompt has to travel as a message rather than the top-level
|
|
567
|
+
// `system` field, because only a message carries `providerOptions`.
|
|
568
|
+
//
|
|
569
|
+
// Two breakpoints, both on content that is append-only:
|
|
570
|
+
// 1. system (+ tools) — identical for every round of a segment, and for
|
|
571
|
+
// every turn on the same page.
|
|
572
|
+
// 2. the end of the incoming history — stable across the rounds that follow
|
|
573
|
+
// it, since a round appends rather than rewrites.
|
|
574
|
+
//
|
|
575
|
+
// Caching is a PREFIX match: one byte earlier in the prefix invalidates
|
|
576
|
+
// everything after it. Nothing volatile may move ahead of these points — no
|
|
577
|
+
// timestamp or request id in the system prompt, and a stable tool order.
|
|
578
|
+
// A new segment changes the tool set, so it writes a new prefix; that happens
|
|
579
|
+
// only when the page changes. `cacheReadTokens` in the usage below is how you
|
|
580
|
+
// tell caching is still working.
|
|
581
|
+
const cachePoint = config.promptCacheBreakpoint;
|
|
582
|
+
// ─── The turn after an approval decision (ADR-0228 §2.2.5) ────────────────
|
|
583
|
+
const continued = input.approval ? await continueApproval(input.approval) : null;
|
|
584
|
+
// Convert history to AI SDK CoreMessage format. The page the user is on
|
|
585
|
+
// travels with their message, not in the system prompt: it changes on every
|
|
586
|
+
// page, and in the system prompt it would invalidate the cached prefix. An
|
|
587
|
+
// approved call that ran follows the user's message, as the call it was.
|
|
588
|
+
const messages = [
|
|
589
|
+
...withPageState(convertHistoryToCoreMessages(input.history ?? [], [input.content, continued?.note].filter(Boolean).join('\n\n')), snapshot, config.ui?.maxObservationChars ?? DEFAULT_PAGE_STATE_CHARS),
|
|
590
|
+
...(continued?.messages ?? []),
|
|
591
|
+
];
|
|
592
|
+
// The system prompt goes in `instructions`, NOT in `messages`.
|
|
593
|
+
//
|
|
594
|
+
// ai v7 rejects a system-role entry in `messages` outright —
|
|
595
|
+
// AI_InvalidPromptError, "System messages are not allowed in the prompt or
|
|
596
|
+
// messages fields" — because `allowSystemInMessages` defaults to false. An
|
|
597
|
+
// earlier attempt at these breakpoints put it there and broke every turn.
|
|
598
|
+
//
|
|
599
|
+
// `instructions` takes `string | SystemModelMessage | SystemModelMessage[]`,
|
|
600
|
+
// and the message form carries `providerOptions`, so the breakpoint travels
|
|
601
|
+
// with it. (`system` is the deprecated alias for the same type.)
|
|
602
|
+
const instructions = {
|
|
603
|
+
role: 'system',
|
|
604
|
+
content: systemPrompt,
|
|
605
|
+
...(cachePoint ? { providerOptions: cachePoint } : {}),
|
|
606
|
+
};
|
|
607
|
+
const withCachePoint = (msgs) => !cachePoint ? msgs : [
|
|
608
|
+
...msgs.slice(0, -1),
|
|
609
|
+
...msgs.slice(-1).map((m) => ({
|
|
610
|
+
...m,
|
|
611
|
+
providerOptions: { ...(m.providerOptions ?? {}), ...cachePoint },
|
|
612
|
+
})),
|
|
613
|
+
];
|
|
614
|
+
const allSteps = [];
|
|
615
|
+
let conversation = messages;
|
|
616
|
+
let segments = 0;
|
|
617
|
+
let responseMessageCount = 0;
|
|
618
|
+
const usageTotals = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 };
|
|
619
|
+
for (;;) {
|
|
620
|
+
segments++;
|
|
621
|
+
const aiTools = assembleTools();
|
|
622
|
+
const previousSteps = [...allSteps];
|
|
623
|
+
log('info', 'agent:llm', 'Messages prepared', {
|
|
624
|
+
turnId,
|
|
625
|
+
segment: segments,
|
|
626
|
+
messageCount: conversation.length,
|
|
627
|
+
toolCount: Object.keys(aiTools).length,
|
|
628
|
+
});
|
|
629
|
+
const result = streamText({
|
|
630
|
+
model: config.model,
|
|
631
|
+
instructions,
|
|
632
|
+
messages: withCachePoint(conversation),
|
|
633
|
+
tools: aiTools,
|
|
634
|
+
maxOutputTokens: maxTokens,
|
|
635
|
+
maxRetries: retries,
|
|
636
|
+
abortSignal: abortController.signal,
|
|
637
|
+
stopWhen: [
|
|
638
|
+
isStepCount(maxRounds - allSteps.length),
|
|
639
|
+
...USER_INPUT_TOOLS.map((name) => hasToolCall(name)),
|
|
640
|
+
// A call is waiting for the user's approval: the turn ends here (ADR-0228).
|
|
641
|
+
() => approvalHold !== null,
|
|
642
|
+
// The page now offers different actions: end this segment so the next
|
|
643
|
+
// one is built from them (ADR-0209 D3).
|
|
644
|
+
() => pendingSurfaces !== null,
|
|
645
|
+
],
|
|
646
|
+
temperature,
|
|
647
|
+
telemetry: {
|
|
648
|
+
isEnabled: true,
|
|
649
|
+
functionId: `agent-turn:${turnId}`,
|
|
650
|
+
},
|
|
651
|
+
prepareStep: async ({ steps }) => {
|
|
652
|
+
const { note, ...constraints } = await turnPolicy.prepareStep({
|
|
653
|
+
// The policy counts steps across the whole turn, not per segment.
|
|
654
|
+
steps: [...previousSteps, ...steps],
|
|
655
|
+
turnClass,
|
|
656
|
+
allToolNames: Object.keys(aiTools),
|
|
657
|
+
});
|
|
658
|
+
// The policy's note goes after the system prompt, for this step only.
|
|
659
|
+
// ai carries an instructions override forward to later steps, so a step
|
|
660
|
+
// without a note sets the plain instructions back.
|
|
661
|
+
return {
|
|
662
|
+
...constraints,
|
|
663
|
+
instructions: note ? [instructions, { role: 'system', content: note }] : instructions,
|
|
664
|
+
};
|
|
665
|
+
},
|
|
666
|
+
onStepEnd: async ({ text, toolCalls }) => {
|
|
667
|
+
log('info', 'agent:llm', 'Step completed', {
|
|
668
|
+
turnId,
|
|
669
|
+
turnClass,
|
|
670
|
+
segment: segments,
|
|
671
|
+
textLength: text?.length ?? 0,
|
|
672
|
+
toolCallCount: toolCalls?.length ?? 0,
|
|
673
|
+
totalTextEmitted,
|
|
674
|
+
});
|
|
675
|
+
},
|
|
676
|
+
});
|
|
677
|
+
for await (const textPart of result.textStream) {
|
|
678
|
+
tokenBuffer += textPart;
|
|
679
|
+
if (tokenBuffer.length >= COALESCE_CHARS) {
|
|
680
|
+
await flushTokenBuffer();
|
|
681
|
+
}
|
|
682
|
+
else if (!flushTimer) {
|
|
683
|
+
flushTimer = setTimeout(() => flushTokenBuffer(), COALESCE_MS);
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
await flushTokenBuffer();
|
|
687
|
+
// After the text stream completes, all promised properties resolve.
|
|
688
|
+
const steps = await result.steps;
|
|
689
|
+
const segmentUsage = await result.usage;
|
|
690
|
+
const response = await result.response;
|
|
691
|
+
allSteps.push(...steps);
|
|
692
|
+
usageTotals.inputTokens += segmentUsage?.inputTokens ?? 0;
|
|
693
|
+
usageTotals.outputTokens += segmentUsage?.outputTokens ?? 0;
|
|
694
|
+
usageTotals.cacheReadTokens += segmentUsage?.inputTokenDetails?.cacheReadTokens ?? 0;
|
|
695
|
+
usageTotals.cacheWriteTokens += segmentUsage?.inputTokenDetails?.cacheWriteTokens ?? 0;
|
|
696
|
+
responseMessageCount += response.messages.length;
|
|
697
|
+
conversation = [...conversation, ...response.messages];
|
|
698
|
+
const askedUser = steps.some((s) => s.toolCalls?.some((tc) => USER_INPUT_TOOLS.includes(tc.toolName)));
|
|
699
|
+
const next = pendingSurfaces;
|
|
700
|
+
if (next && !askedUser && !approvalHold && allSteps.length < maxRounds) {
|
|
701
|
+
log('info', 'agent:ui', 'The page changed; continuing the turn with its actions', {
|
|
702
|
+
turnId,
|
|
703
|
+
from: currentSurfaces.map((s) => s.id),
|
|
704
|
+
to: next.map((s) => s.id),
|
|
705
|
+
});
|
|
706
|
+
currentSurfaces = next;
|
|
707
|
+
pendingSurfaces = null;
|
|
708
|
+
continue;
|
|
709
|
+
}
|
|
710
|
+
if (next) {
|
|
711
|
+
// The turn ends here anyway; the next turn starts from the client's new snapshot.
|
|
712
|
+
currentSurfaces = next;
|
|
713
|
+
pendingSurfaces = null;
|
|
714
|
+
}
|
|
715
|
+
break;
|
|
716
|
+
}
|
|
717
|
+
// ─── Finalize ──────────────────────────────────────────────────────────────
|
|
718
|
+
const steps = allSteps;
|
|
719
|
+
// Determine stop reason
|
|
720
|
+
let stopReason = 'complete';
|
|
721
|
+
if (approvalHold) {
|
|
722
|
+
stopReason = 'awaiting_approval';
|
|
723
|
+
}
|
|
724
|
+
else if (steps.length >= maxRounds) {
|
|
725
|
+
stopReason = 'step_count';
|
|
726
|
+
}
|
|
727
|
+
else if (steps.some((s) => s.toolCalls?.some(tc => USER_INPUT_TOOLS.includes(tc.toolName)))) {
|
|
728
|
+
stopReason = 'present_options';
|
|
729
|
+
}
|
|
730
|
+
// deadline and token_budget are caught by the try/catch — they produce errors, not normal returns
|
|
731
|
+
// ─── Usage ─────────────────────────────────────────────────────────────────
|
|
732
|
+
// `promptTokens` is the SUM over rounds, and it was the only number reported.
|
|
733
|
+
// Read as a context measurement it is wrong by a factor of the round count: a
|
|
734
|
+
// five-round turn showed 128,808 tokens when no single call exceeded ~26,000,
|
|
735
|
+
// so the context ceiling fired on turns that were nowhere near the window and
|
|
736
|
+
// said nothing about the ones that were.
|
|
737
|
+
//
|
|
738
|
+
// Both numbers matter, and they answer different questions:
|
|
739
|
+
// peakPromptTokens — the largest single call. What the context window sees,
|
|
740
|
+
// and what the ceiling must be compared against.
|
|
741
|
+
// promptTokens — the sum. What the turn costs.
|
|
742
|
+
//
|
|
743
|
+
// cacheReadTokens/cacheWriteTokens are the caching gauge: a read of roughly
|
|
744
|
+
// the prefix size on every round after the first means the breakpoints above
|
|
745
|
+
// are holding. Reads at zero across a multi-round turn mean something
|
|
746
|
+
// volatile has moved into the prefix and the cache is being missed silently.
|
|
747
|
+
const stepInputTokens = steps.map((s) => s.usage?.inputTokens ?? 0);
|
|
748
|
+
const usage = {
|
|
749
|
+
promptTokens: usageTotals.inputTokens,
|
|
750
|
+
completionTokens: usageTotals.outputTokens,
|
|
751
|
+
totalTokens: usageTotals.inputTokens + usageTotals.outputTokens,
|
|
752
|
+
peakPromptTokens: stepInputTokens.length > 0 ? Math.max(...stepInputTokens) : 0,
|
|
753
|
+
cacheReadTokens: usageTotals.cacheReadTokens,
|
|
754
|
+
cacheWriteTokens: usageTotals.cacheWriteTokens,
|
|
755
|
+
};
|
|
756
|
+
log('info', 'agent:llm', 'Turn usage', {
|
|
757
|
+
turnId,
|
|
758
|
+
rounds: steps.length,
|
|
759
|
+
segments,
|
|
760
|
+
peakPromptTokens: usage.peakPromptTokens,
|
|
761
|
+
promptTokens: usage.promptTokens,
|
|
762
|
+
cacheReadTokens: usage.cacheReadTokens,
|
|
763
|
+
cacheWriteTokens: usage.cacheWriteTokens,
|
|
764
|
+
});
|
|
765
|
+
await config.emit.emit(socketRoom, AGENT_SOCKET_EVENTS.TURN_COMPLETE, {
|
|
766
|
+
turnId,
|
|
767
|
+
rounds: steps.length,
|
|
768
|
+
usage,
|
|
769
|
+
timestamp: Date.now(),
|
|
770
|
+
});
|
|
771
|
+
log('info', 'agent:turn', 'Turn completed', {
|
|
772
|
+
turnId,
|
|
773
|
+
conversationId: input.conversationId,
|
|
774
|
+
totalRounds: steps.length,
|
|
775
|
+
segments,
|
|
776
|
+
maxRoundsReached: steps.length >= maxRounds,
|
|
777
|
+
usage,
|
|
778
|
+
newMessageCount: responseMessageCount,
|
|
779
|
+
});
|
|
780
|
+
// Convert AI SDK response messages to our TurnMessage format for persistence
|
|
781
|
+
const newMessages = [...(continued?.persisted ?? []), ...convertResponseToTurnMessages({ steps }, executedToolResults)];
|
|
782
|
+
return {
|
|
783
|
+
rounds: steps.length,
|
|
784
|
+
usage,
|
|
785
|
+
newMessages,
|
|
786
|
+
maxRoundsReached: steps.length >= maxRounds,
|
|
787
|
+
stopReason,
|
|
788
|
+
};
|
|
789
|
+
}
|
|
790
|
+
finally {
|
|
791
|
+
clearTimeout(deadlineTimer);
|
|
792
|
+
if (flushTimer)
|
|
793
|
+
clearTimeout(flushTimer);
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
/**
|
|
797
|
+
* Put the client's page state on the user's message: which surfaces are
|
|
798
|
+
* mounted and their latest observations. The model reads it as part of what
|
|
799
|
+
* the user said, where it is true for this message only.
|
|
800
|
+
*/
|
|
801
|
+
function withPageState(messages, snapshot, maxChars) {
|
|
802
|
+
if (!snapshot || snapshot.surfaces.length === 0)
|
|
803
|
+
return messages;
|
|
804
|
+
const last = messages[messages.length - 1];
|
|
805
|
+
if (!last || last.role !== 'user')
|
|
806
|
+
return messages;
|
|
807
|
+
const observations = observationsText(snapshot.observations, maxChars);
|
|
808
|
+
const pageState = [
|
|
809
|
+
'<page_state>',
|
|
810
|
+
`The user's screen offers: ${snapshot.surfaces.map((s) => `${s.name} (${s.id})`).join(', ')}.`,
|
|
811
|
+
`Current values: ${observations}`,
|
|
812
|
+
'</page_state>',
|
|
813
|
+
].join('\n');
|
|
814
|
+
const text = typeof last.content === 'string'
|
|
815
|
+
? last.content
|
|
816
|
+
: last.content.map((p) => ('text' in p ? p.text : '')).join('');
|
|
817
|
+
return [...messages.slice(0, -1), { role: 'user', content: `${text}\n\n${pageState}` }];
|
|
818
|
+
}
|
|
819
|
+
/**
|
|
820
|
+
* Convert our TurnHistoryMessage[] to AI SDK CoreMessage[].
|
|
821
|
+
* The AI SDK handles each provider's message format internally.
|
|
822
|
+
*
|
|
823
|
+
* DEFENSIVE: If an assistant message has tool_calls but a matching tool result
|
|
824
|
+
* is missing from history, we synthesize a placeholder result. This prevents
|
|
825
|
+
* AI_MissingToolResultsError crashes when persistence has gaps (e.g., stopWhen
|
|
826
|
+
* triggered before a tool result was persisted).
|
|
827
|
+
*/
|
|
828
|
+
function convertHistoryToCoreMessages(history, currentContent) {
|
|
829
|
+
const messages = [];
|
|
830
|
+
// First pass: collect all tool_call_ids that have results in history
|
|
831
|
+
// AND all tool call IDs from assistant messages (to validate results)
|
|
832
|
+
const answeredToolCallIds = new Set();
|
|
833
|
+
const knownToolCallIds = new Set();
|
|
834
|
+
for (const msg of history) {
|
|
835
|
+
if (msg.role === 'tool' && msg.tool_call_id) {
|
|
836
|
+
answeredToolCallIds.add(msg.tool_call_id);
|
|
837
|
+
}
|
|
838
|
+
if (msg.role === 'assistant' && msg.tool_calls?.length) {
|
|
839
|
+
for (const tc of msg.tool_calls) {
|
|
840
|
+
knownToolCallIds.add(tc.id);
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
log('info', 'agent:llm', 'History conversion diagnostics', {
|
|
845
|
+
sdkVersion: '2.4.5-diag',
|
|
846
|
+
historyLength: history.length,
|
|
847
|
+
answeredToolCallIds: Array.from(answeredToolCallIds),
|
|
848
|
+
knownToolCallIds: Array.from(knownToolCallIds),
|
|
849
|
+
toolMessages: history.filter(m => m.role === 'tool').map(m => ({
|
|
850
|
+
tool_call_id: m.tool_call_id,
|
|
851
|
+
name: m.name,
|
|
852
|
+
inKnown: knownToolCallIds.has(m.tool_call_id ?? ''),
|
|
853
|
+
})),
|
|
854
|
+
});
|
|
855
|
+
for (let idx = 0; idx < history.length; idx++) {
|
|
856
|
+
const msg = history[idx];
|
|
857
|
+
if (msg.role === 'user') {
|
|
858
|
+
messages.push({ role: 'user', content: msg.content });
|
|
859
|
+
}
|
|
860
|
+
else if (msg.role === 'assistant') {
|
|
861
|
+
if (msg.content || msg.tool_calls?.length) {
|
|
862
|
+
const parts = [];
|
|
863
|
+
if (msg.content) {
|
|
864
|
+
parts.push({ type: 'text', text: msg.content });
|
|
865
|
+
}
|
|
866
|
+
if (msg.tool_calls?.length) {
|
|
867
|
+
for (const tc of msg.tool_calls) {
|
|
868
|
+
let toolName;
|
|
869
|
+
let args;
|
|
870
|
+
if (tc.function && typeof tc.function === 'object') {
|
|
871
|
+
toolName = tc.function.name;
|
|
872
|
+
try {
|
|
873
|
+
args = typeof tc.function.arguments === 'string'
|
|
874
|
+
? JSON.parse(tc.function.arguments)
|
|
875
|
+
: tc.function.arguments;
|
|
876
|
+
}
|
|
877
|
+
catch {
|
|
878
|
+
args = {};
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
else {
|
|
882
|
+
const flat = tc;
|
|
883
|
+
toolName = flat.name;
|
|
884
|
+
try {
|
|
885
|
+
args = typeof flat.arguments === 'string'
|
|
886
|
+
? JSON.parse(flat.arguments)
|
|
887
|
+
: flat.arguments ?? {};
|
|
888
|
+
}
|
|
889
|
+
catch {
|
|
890
|
+
args = {};
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
parts.push({ type: 'tool-call', toolCallId: tc.id, toolName, input: args });
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
messages.push({ role: 'assistant', content: parts });
|
|
897
|
+
// DEFENSIVE: synthesize missing tool results for any tool_calls
|
|
898
|
+
// that don't have a matching tool result in history.
|
|
899
|
+
if (msg.tool_calls?.length) {
|
|
900
|
+
for (const tc of msg.tool_calls) {
|
|
901
|
+
if (!answeredToolCallIds.has(tc.id)) {
|
|
902
|
+
const toolName = tc.function?.name ?? tc.name ?? 'unknown';
|
|
903
|
+
log('info', 'agent:llm', 'SYNTHESIZING tool result (missing from history)', {
|
|
904
|
+
toolCallId: tc.id,
|
|
905
|
+
toolName,
|
|
906
|
+
answeredIds: Array.from(answeredToolCallIds),
|
|
907
|
+
});
|
|
908
|
+
messages.push({
|
|
909
|
+
role: 'tool',
|
|
910
|
+
content: [{
|
|
911
|
+
type: 'tool-result',
|
|
912
|
+
toolCallId: tc.id,
|
|
913
|
+
toolName,
|
|
914
|
+
output: { type: 'text', value: JSON.stringify({ acknowledged: true }) },
|
|
915
|
+
}],
|
|
916
|
+
});
|
|
917
|
+
// Mark as answered so we don't double-synthesize
|
|
918
|
+
answeredToolCallIds.add(tc.id);
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
else if (msg.role === 'tool') {
|
|
925
|
+
// Skip tool results that are corrupted or orphaned:
|
|
926
|
+
// 1. Empty tool_call_id (persistence bug: toolCallId was undefined)
|
|
927
|
+
// 2. tool_call_id that doesn't match ANY known tool call in the conversation
|
|
928
|
+
// (persistence bug: fallback ID 'tool_${timestamp}' instead of real ID)
|
|
929
|
+
// The defensive synthesis above creates proper results for the real tool calls.
|
|
930
|
+
if (!msg.tool_call_id || !knownToolCallIds.has(msg.tool_call_id)) {
|
|
931
|
+
log('info', 'agent:llm', 'SKIPPING orphaned tool result', {
|
|
932
|
+
tool_call_id: msg.tool_call_id,
|
|
933
|
+
name: msg.name,
|
|
934
|
+
inKnown: msg.tool_call_id ? knownToolCallIds.has(msg.tool_call_id) : false,
|
|
935
|
+
});
|
|
936
|
+
continue;
|
|
937
|
+
}
|
|
938
|
+
log('info', 'agent:llm', 'KEEPING valid tool result', {
|
|
939
|
+
tool_call_id: msg.tool_call_id,
|
|
940
|
+
name: msg.name,
|
|
941
|
+
});
|
|
942
|
+
// Tool result output must use discriminated format: { type: 'text', value: string }
|
|
943
|
+
const outputValue = msg.content || 'acknowledged';
|
|
944
|
+
messages.push({
|
|
945
|
+
role: 'tool',
|
|
946
|
+
content: [{
|
|
947
|
+
type: 'tool-result',
|
|
948
|
+
toolCallId: msg.tool_call_id,
|
|
949
|
+
toolName: msg.name ?? 'unknown',
|
|
950
|
+
output: { type: 'text', value: outputValue },
|
|
951
|
+
}],
|
|
952
|
+
});
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
// Add current user message if not already at the end
|
|
956
|
+
const lastMsg = messages[messages.length - 1];
|
|
957
|
+
if (!lastMsg || lastMsg.role !== 'user') {
|
|
958
|
+
messages.push({ role: 'user', content: currentContent });
|
|
959
|
+
}
|
|
960
|
+
// Final diagnostics: summary of converted messages
|
|
961
|
+
log('info', 'agent:llm', 'Converted messages summary', {
|
|
962
|
+
totalMessages: messages.length,
|
|
963
|
+
byRole: messages.reduce((acc, m) => {
|
|
964
|
+
acc[m.role] = (acc[m.role] || 0) + 1;
|
|
965
|
+
return acc;
|
|
966
|
+
}, {}),
|
|
967
|
+
toolResults: messages
|
|
968
|
+
.filter((m) => m.role === 'tool')
|
|
969
|
+
.map((m) => {
|
|
970
|
+
const first = m.content[0];
|
|
971
|
+
return {
|
|
972
|
+
toolCallId: first && 'toolCallId' in first ? first.toolCallId : undefined,
|
|
973
|
+
toolName: first && 'toolName' in first ? first.toolName : undefined,
|
|
974
|
+
};
|
|
975
|
+
}),
|
|
976
|
+
assistantToolCalls: messages
|
|
977
|
+
.filter((m) => m.role === 'assistant')
|
|
978
|
+
.map((m) => ({
|
|
979
|
+
hasToolCalls: Array.isArray(m.content) && m.content.some((p) => p.type === 'tool-call'),
|
|
980
|
+
toolCallIds: Array.isArray(m.content)
|
|
981
|
+
? m.content.filter((p) => p.type === 'tool-call').map((p) => p.toolCallId)
|
|
982
|
+
: [],
|
|
983
|
+
})),
|
|
984
|
+
});
|
|
985
|
+
return messages;
|
|
986
|
+
}
|
|
987
|
+
function convertResponseToTurnMessages(result, executedToolResults) {
|
|
988
|
+
const turnMessages = [];
|
|
989
|
+
let resultIdx = 0;
|
|
990
|
+
for (const step of result.steps) {
|
|
991
|
+
// Assistant message with text and/or tool calls
|
|
992
|
+
if (step.text || step.toolCalls.length > 0) {
|
|
993
|
+
const msg = {
|
|
994
|
+
role: 'assistant',
|
|
995
|
+
content: step.text || null,
|
|
996
|
+
};
|
|
997
|
+
if (step.toolCalls.length > 0) {
|
|
998
|
+
msg.toolCalls = step.toolCalls.map((tc) => ({
|
|
999
|
+
id: tc.toolCallId,
|
|
1000
|
+
name: tc.toolName,
|
|
1001
|
+
arguments: (tc.args ?? tc.input ?? {}),
|
|
1002
|
+
}));
|
|
1003
|
+
}
|
|
1004
|
+
turnMessages.push(msg);
|
|
1005
|
+
// Add tool results for the tool calls in this step
|
|
1006
|
+
for (let i = 0; i < step.toolCalls.length && resultIdx < executedToolResults.length; i++) {
|
|
1007
|
+
const tracked = executedToolResults[resultIdx++];
|
|
1008
|
+
turnMessages.push({
|
|
1009
|
+
role: 'tool',
|
|
1010
|
+
content: tracked.result,
|
|
1011
|
+
toolCallId: tracked.toolCallId,
|
|
1012
|
+
name: tracked.toolName,
|
|
1013
|
+
});
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
return turnMessages;
|
|
1018
|
+
}
|
|
1019
|
+
//# sourceMappingURL=orchestrator.js.map
|