@ariaflowagents/core 0.5.1 → 0.5.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +51 -1
- package/dist/agents/TriageAgent.d.ts.map +1 -1
- package/dist/agents/TriageAgent.js +4 -3
- package/dist/agents/TriageAgent.js.map +1 -1
- package/dist/flows/AgentFlowManager.d.ts +3 -1
- package/dist/flows/AgentFlowManager.d.ts.map +1 -1
- package/dist/flows/AgentFlowManager.js +34 -5
- package/dist/flows/AgentFlowManager.js.map +1 -1
- package/dist/flows/FlowManager.d.ts +22 -2
- package/dist/flows/FlowManager.d.ts.map +1 -1
- package/dist/flows/FlowManager.js +114 -21
- package/dist/flows/FlowManager.js.map +1 -1
- package/dist/flows/index.d.ts +1 -1
- package/dist/flows/index.d.ts.map +1 -1
- package/dist/flows/index.js +1 -1
- package/dist/flows/index.js.map +1 -1
- package/dist/flows/template.d.ts +13 -0
- package/dist/flows/template.d.ts.map +1 -0
- package/dist/flows/template.js +64 -0
- package/dist/flows/template.js.map +1 -0
- package/dist/flows/transitions.d.ts +4 -0
- package/dist/flows/transitions.d.ts.map +1 -1
- package/dist/flows/transitions.js +9 -0
- package/dist/flows/transitions.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/processors/ProcessorRunner.d.ts +39 -0
- package/dist/processors/ProcessorRunner.d.ts.map +1 -0
- package/dist/processors/ProcessorRunner.js +47 -0
- package/dist/processors/ProcessorRunner.js.map +1 -0
- package/dist/runtime/InjectionQueue.d.ts +10 -0
- package/dist/runtime/InjectionQueue.d.ts.map +1 -1
- package/dist/runtime/InjectionQueue.js +14 -0
- package/dist/runtime/InjectionQueue.js.map +1 -1
- package/dist/runtime/Runtime.d.ts +15 -1
- package/dist/runtime/Runtime.d.ts.map +1 -1
- package/dist/runtime/Runtime.js +570 -71
- package/dist/runtime/Runtime.js.map +1 -1
- package/dist/tools/Tool.d.ts.map +1 -1
- package/dist/tools/Tool.js +11 -2
- package/dist/tools/Tool.js.map +1 -1
- package/dist/tools/handoff.d.ts +0 -1
- package/dist/tools/handoff.d.ts.map +1 -1
- package/dist/tools/handoff.js +6 -4
- package/dist/tools/handoff.js.map +1 -1
- package/dist/tools/http.d.ts +3 -3
- package/dist/tools/http.d.ts.map +1 -1
- package/dist/tools/http.js +4 -3
- package/dist/tools/http.js.map +1 -1
- package/dist/tools/http.types.d.ts.map +1 -1
- package/dist/types/index.d.ts +120 -1
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/index.js.map +1 -1
- package/dist/utils/chrono.d.ts +2 -2
- package/dist/utils/chrono.d.ts.map +1 -1
- package/dist/utils/chrono.js +4 -1
- package/dist/utils/chrono.js.map +1 -1
- package/guides/FLOWS.md +79 -0
- package/guides/GETTING_STARTED.md +58 -0
- package/guides/GUARDRAILS.md +85 -0
- package/guides/README.md +16 -0
- package/guides/RUNTIME.md +88 -0
- package/guides/TOOLS.md +66 -0
- package/package.json +4 -2
package/dist/runtime/Runtime.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { streamText, generateText,
|
|
1
|
+
import { streamText, generateText, Output, stepCountIs } from 'ai';
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import { FlowManager } from '../flows/FlowManager.js';
|
|
4
4
|
import { createHandoffTool, isHandoffResult } from '../tools/handoff.js';
|
|
@@ -10,6 +10,8 @@ import { ToolEnforcer } from '../guards/ToolEnforcer.js';
|
|
|
10
10
|
import { defaultEnforcementRules } from '../guards/rules.js';
|
|
11
11
|
import { MemoryStore } from '../session/stores/MemoryStore.js';
|
|
12
12
|
import { createHttpCallback } from '../callbacks/httpCallback.js';
|
|
13
|
+
import { compileSanitizePattern, renderFlowTemplate } from '../flows/template.js';
|
|
14
|
+
import { runInputProcessors, runOutputProcessors } from '../processors/ProcessorRunner.js';
|
|
13
15
|
export class Runtime {
|
|
14
16
|
config;
|
|
15
17
|
agents = new Map();
|
|
@@ -18,7 +20,7 @@ export class Runtime {
|
|
|
18
20
|
maxSteps;
|
|
19
21
|
maxHandoffs;
|
|
20
22
|
stopConditions;
|
|
21
|
-
|
|
23
|
+
_sessionStore;
|
|
22
24
|
hookRunner;
|
|
23
25
|
enforcer;
|
|
24
26
|
contextManager;
|
|
@@ -26,6 +28,50 @@ export class Runtime {
|
|
|
26
28
|
abortControllers = new Map();
|
|
27
29
|
alwaysRouteThroughTriage;
|
|
28
30
|
triageAgentId;
|
|
31
|
+
inputProcessors;
|
|
32
|
+
outputProcessors;
|
|
33
|
+
outputProcessorMode;
|
|
34
|
+
outputRedactions;
|
|
35
|
+
redactCarryBySession = new Map();
|
|
36
|
+
redactLookbehind = 64;
|
|
37
|
+
wrapToolsWithEnforcement(context, tools) {
|
|
38
|
+
const wrapped = {};
|
|
39
|
+
for (const [toolName, toolDef] of Object.entries(tools ?? {})) {
|
|
40
|
+
const exec = toolDef?.execute;
|
|
41
|
+
if (typeof exec !== 'function') {
|
|
42
|
+
wrapped[toolName] = toolDef;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
wrapped[toolName] = {
|
|
46
|
+
...toolDef,
|
|
47
|
+
execute: async (args, options) => {
|
|
48
|
+
const callRecord = {
|
|
49
|
+
toolCallId: options?.toolCallId ?? crypto.randomUUID(),
|
|
50
|
+
toolName,
|
|
51
|
+
args,
|
|
52
|
+
success: true,
|
|
53
|
+
timestamp: Date.now(),
|
|
54
|
+
};
|
|
55
|
+
const enforcement = await this.enforcer.check(callRecord, {
|
|
56
|
+
previousCalls: context.toolCallHistory,
|
|
57
|
+
currentStep: context.stepCount,
|
|
58
|
+
sessionState: context.session.state ?? {},
|
|
59
|
+
});
|
|
60
|
+
if (!enforcement.allowed) {
|
|
61
|
+
const reason = enforcement.reason ?? 'Tool call blocked by enforcement';
|
|
62
|
+
callRecord.success = false;
|
|
63
|
+
callRecord.error = new Error(reason);
|
|
64
|
+
context.toolCallHistory.push(callRecord);
|
|
65
|
+
await this.hookRunner.onToolError(context, callRecord, callRecord.error);
|
|
66
|
+
throw callRecord.error;
|
|
67
|
+
}
|
|
68
|
+
// Preserve AI SDK tool execution context (toolCallId, messages, experimental_context, etc.).
|
|
69
|
+
return exec(args, options);
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
return wrapped;
|
|
74
|
+
}
|
|
29
75
|
constructor(config) {
|
|
30
76
|
this.config = config;
|
|
31
77
|
for (const agent of config.agents) {
|
|
@@ -36,7 +82,7 @@ export class Runtime {
|
|
|
36
82
|
this.maxSteps = config.maxSteps ?? 20;
|
|
37
83
|
this.maxHandoffs = config.maxHandoffs ?? 10;
|
|
38
84
|
this.stopConditions = config.stopConditions ?? defaultStopConditions;
|
|
39
|
-
this.
|
|
85
|
+
this._sessionStore = config.sessionStore ?? new MemoryStore();
|
|
40
86
|
const hooks = { ...config.hooks };
|
|
41
87
|
if (config.callback) {
|
|
42
88
|
console.log('[Runtime] HTTP callback configured for:', config.callback.url);
|
|
@@ -58,6 +104,20 @@ export class Runtime {
|
|
|
58
104
|
this.contextManager = config.contextManager;
|
|
59
105
|
this.alwaysRouteThroughTriage = config.alwaysRouteThroughTriage ?? false;
|
|
60
106
|
this.triageAgentId = config.triageAgentId;
|
|
107
|
+
this.inputProcessors = config.inputProcessors ?? [];
|
|
108
|
+
this.outputProcessors = config.outputProcessors ?? [];
|
|
109
|
+
this.outputProcessorMode = config.outputProcessorMode ?? 'stream';
|
|
110
|
+
if (config.outputRedaction && config.outputRedaction.length > 0) {
|
|
111
|
+
this.outputRedactions = config.outputRedaction.map(r => {
|
|
112
|
+
const base = compileSanitizePattern(r.pattern);
|
|
113
|
+
const flags = base.flags.includes('g') ? base.flags : `${base.flags}g`;
|
|
114
|
+
const re = new RegExp(base.source, flags);
|
|
115
|
+
return { re, replacement: r.replacement };
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
get sessionStore() {
|
|
120
|
+
return this._sessionStore;
|
|
61
121
|
}
|
|
62
122
|
getActiveAbortController(sessionId) {
|
|
63
123
|
return this.abortControllers.get(sessionId);
|
|
@@ -74,49 +134,34 @@ export class Runtime {
|
|
|
74
134
|
let session;
|
|
75
135
|
const effectiveSessionId = sessionId ?? crypto.randomUUID();
|
|
76
136
|
if (sessionId) {
|
|
77
|
-
const existing = await this.
|
|
137
|
+
const existing = await this._sessionStore.get(sessionId);
|
|
78
138
|
session = existing ?? this.createSession(effectiveSessionId, userId);
|
|
79
139
|
}
|
|
80
140
|
else {
|
|
81
141
|
session = this.createSession(effectiveSessionId, userId);
|
|
82
142
|
}
|
|
83
|
-
session.messages.push({ role: 'user', content: input });
|
|
84
|
-
if (this.contextManager) {
|
|
85
|
-
const messagesBefore = session.messages.length;
|
|
86
|
-
const totalTokens = session.messages.reduce((sum, m) => {
|
|
87
|
-
const text = typeof m.content === 'string' ? m.content : '';
|
|
88
|
-
return sum + Math.ceil(text.length / 4);
|
|
89
|
-
}, 0);
|
|
90
|
-
session.messages = this.contextManager.beforeTurn(session.messages, {
|
|
91
|
-
turnCount: this.turnCount,
|
|
92
|
-
totalTokens,
|
|
93
|
-
sessionId: session.id,
|
|
94
|
-
});
|
|
95
|
-
const messagesAfter = session.messages.length;
|
|
96
|
-
if (messagesBefore !== messagesAfter) {
|
|
97
|
-
// NOTE: This is the only yield that bypasses emit() because RunContext
|
|
98
|
-
// doesn't exist yet at this point. The onStreamPart hook will NOT fire
|
|
99
|
-
// for context-compacted events. This is intentional - context compaction
|
|
100
|
-
// occurs before the agent loop begins.
|
|
101
|
-
yield {
|
|
102
|
-
type: 'context-compacted',
|
|
103
|
-
messagesBefore,
|
|
104
|
-
messagesAfter,
|
|
105
|
-
};
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
this.turnCount++;
|
|
109
143
|
if (this.alwaysRouteThroughTriage) {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
144
|
+
// If a flow agent is currently active and has an initialized, non-ended flow state,
|
|
145
|
+
// keep routing to it so multi-turn flows can complete without triage interference.
|
|
146
|
+
const currentAgentId = session.activeAgentId ?? session.currentAgent;
|
|
147
|
+
const currentAgent = currentAgentId ? this.agents.get(currentAgentId) : undefined;
|
|
148
|
+
const hasActiveFlow = Boolean(currentAgent) &&
|
|
149
|
+
this.isFlowAgent(currentAgent) &&
|
|
150
|
+
Boolean(this.getFlowState(session, currentAgent.id)?.initialized) &&
|
|
151
|
+
!Boolean(this.getFlowState(session, currentAgent.id)?.flowEnded);
|
|
152
|
+
if (!hasActiveFlow) {
|
|
153
|
+
const triageId = this.triageAgentId ?? this.defaultAgentId;
|
|
154
|
+
const triageAgent = this.agents.get(triageId);
|
|
155
|
+
if (triageAgent && this.isTriageAgent(triageAgent)) {
|
|
156
|
+
session.activeAgentId = triageId;
|
|
157
|
+
session.currentAgent = triageId;
|
|
158
|
+
}
|
|
115
159
|
}
|
|
116
160
|
}
|
|
117
161
|
const activeAgentId = session.activeAgentId ?? session.currentAgent ?? this.defaultAgentId;
|
|
118
162
|
session.activeAgentId = activeAgentId;
|
|
119
163
|
session.currentAgent = activeAgentId;
|
|
164
|
+
this.turnCount++;
|
|
120
165
|
const context = {
|
|
121
166
|
session,
|
|
122
167
|
agentId: activeAgentId,
|
|
@@ -129,10 +174,12 @@ export class Runtime {
|
|
|
129
174
|
};
|
|
130
175
|
const injectionQueue = new InjectionQueue();
|
|
131
176
|
injectionQueue.add(commonInjections.professional);
|
|
132
|
-
// Yield initial input through emit so hooks see it
|
|
133
|
-
yield* this.emit(context, { type: 'input', text: input, userId: session.userId || undefined });
|
|
134
177
|
injectionQueue.add(commonInjections.noGuessing);
|
|
178
|
+
injectionQueue.add(commonInjections.noSecrets);
|
|
179
|
+
injectionQueue.add(commonInjections.invisibleHandoffs);
|
|
135
180
|
injectionQueue.add(commonInjections.confirmDestructive);
|
|
181
|
+
// Yield input through emit so hooks see it (once).
|
|
182
|
+
yield* this.emit(context, { type: 'input', text: input, userId: session.userId || undefined });
|
|
136
183
|
const controller = new AbortController();
|
|
137
184
|
this.abortControllers.set(session.id, controller);
|
|
138
185
|
const abortHandler = () => {
|
|
@@ -146,9 +193,64 @@ export class Runtime {
|
|
|
146
193
|
abortSignal.addEventListener('abort', externalAbortHandler);
|
|
147
194
|
}
|
|
148
195
|
await this.hookRunner.onStart(context);
|
|
149
|
-
|
|
196
|
+
// Run input processors BEFORE persisting the user message.
|
|
197
|
+
let processedInput = input;
|
|
150
198
|
try {
|
|
151
|
-
|
|
199
|
+
const turnInputProcessors = [...this.inputProcessors];
|
|
200
|
+
if (turnInputProcessors.length > 0) {
|
|
201
|
+
const candidateMessages = [
|
|
202
|
+
...session.messages,
|
|
203
|
+
{ role: 'user', content: processedInput },
|
|
204
|
+
];
|
|
205
|
+
const procCtx = {
|
|
206
|
+
session,
|
|
207
|
+
agentId: activeAgentId,
|
|
208
|
+
toolCallHistory: context.toolCallHistory,
|
|
209
|
+
};
|
|
210
|
+
const outcome = await runInputProcessors({
|
|
211
|
+
processors: turnInputProcessors,
|
|
212
|
+
input: processedInput,
|
|
213
|
+
messages: candidateMessages,
|
|
214
|
+
context: procCtx,
|
|
215
|
+
});
|
|
216
|
+
if (outcome.blocked) {
|
|
217
|
+
yield* this.emit(context, {
|
|
218
|
+
type: 'tripwire',
|
|
219
|
+
phase: 'input',
|
|
220
|
+
processorId: outcome.processorId,
|
|
221
|
+
reason: outcome.reason,
|
|
222
|
+
message: outcome.message,
|
|
223
|
+
});
|
|
224
|
+
yield* this.emit(context, { type: 'text-delta', text: outcome.message });
|
|
225
|
+
yield* this.emit(context, { type: 'turn-end' });
|
|
226
|
+
await this.hookRunner.onEnd(context, { success: true });
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
processedInput = outcome.input;
|
|
230
|
+
}
|
|
231
|
+
// Persist the (possibly modified) user message after input processors.
|
|
232
|
+
session.messages.push({ role: 'user', content: processedInput });
|
|
233
|
+
if (this.contextManager) {
|
|
234
|
+
const messagesBefore = session.messages.length;
|
|
235
|
+
const totalTokens = session.messages.reduce((sum, m) => {
|
|
236
|
+
const text = typeof m.content === 'string' ? m.content : '';
|
|
237
|
+
return sum + Math.ceil(text.length / 4);
|
|
238
|
+
}, 0);
|
|
239
|
+
session.messages = this.contextManager.beforeTurn(session.messages, {
|
|
240
|
+
turnCount: this.turnCount,
|
|
241
|
+
totalTokens,
|
|
242
|
+
sessionId: session.id,
|
|
243
|
+
});
|
|
244
|
+
const messagesAfter = session.messages.length;
|
|
245
|
+
if (messagesBefore !== messagesAfter) {
|
|
246
|
+
yield* this.emit(context, {
|
|
247
|
+
type: 'context-compacted',
|
|
248
|
+
messagesBefore,
|
|
249
|
+
messagesAfter,
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
yield* this.runLoop(context, injectionQueue, processedInput, controller);
|
|
152
254
|
await this.hookRunner.onEnd(context, { success: true });
|
|
153
255
|
}
|
|
154
256
|
catch (error) {
|
|
@@ -159,7 +261,7 @@ export class Runtime {
|
|
|
159
261
|
finally {
|
|
160
262
|
controller.signal.removeEventListener('abort', abortHandler);
|
|
161
263
|
this.abortControllers.delete(session.id);
|
|
162
|
-
await this.
|
|
264
|
+
await this._sessionStore.save(session);
|
|
163
265
|
yield* this.emit(context, { type: 'done', sessionId: session.id });
|
|
164
266
|
}
|
|
165
267
|
}
|
|
@@ -167,6 +269,7 @@ export class Runtime {
|
|
|
167
269
|
yield* this.stream({ input, sessionId, userId });
|
|
168
270
|
}
|
|
169
271
|
async *runLoop(context, injectionQueue, input, abortController) {
|
|
272
|
+
let currentInput = input;
|
|
170
273
|
while (context.handoffStack.length < this.maxHandoffs) {
|
|
171
274
|
if (abortController?.signal.aborted) {
|
|
172
275
|
yield* this.emit(context, {
|
|
@@ -179,11 +282,16 @@ export class Runtime {
|
|
|
179
282
|
});
|
|
180
283
|
return;
|
|
181
284
|
}
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
}
|
|
285
|
+
// Allow a single "bounce back" (A -> B -> A) for detours and multi-intent turns.
|
|
286
|
+
// Stop only when an agent would be visited a third time in the same user turn.
|
|
287
|
+
const priorVisits = context.handoffStack.filter(id => id === context.agentId).length;
|
|
288
|
+
if (priorVisits >= 2) {
|
|
289
|
+
const err = `Circular handoff detected: ${context.handoffStack.join(' -> ')} -> ${context.agentId}`;
|
|
290
|
+
yield* this.emit(context, { type: 'error', error: err });
|
|
291
|
+
const fallback = "I ran into an internal routing issue. Please rephrase your request in one sentence. " +
|
|
292
|
+
"For security, don't share passwords, API keys, or card numbers/CVV here.";
|
|
293
|
+
context.session.messages.push({ role: 'assistant', content: fallback });
|
|
294
|
+
yield* this.emit(context, { type: 'text-delta', text: fallback });
|
|
187
295
|
return;
|
|
188
296
|
}
|
|
189
297
|
context.handoffStack.push(context.agentId);
|
|
@@ -192,6 +300,40 @@ export class Runtime {
|
|
|
192
300
|
yield* this.emit(context, { type: 'error', error: `Agent "${context.agentId}" not found` });
|
|
193
301
|
return;
|
|
194
302
|
}
|
|
303
|
+
// Agent-specific input processors. These run after the user message is in history and
|
|
304
|
+
// before the agent gets a chance to call the model/tools.
|
|
305
|
+
if (agent.inputProcessors && agent.inputProcessors.length > 0) {
|
|
306
|
+
const procCtx = {
|
|
307
|
+
session: context.session,
|
|
308
|
+
agentId: agent.id,
|
|
309
|
+
toolCallHistory: context.toolCallHistory,
|
|
310
|
+
};
|
|
311
|
+
const outcome = await runInputProcessors({
|
|
312
|
+
processors: agent.inputProcessors,
|
|
313
|
+
input: currentInput,
|
|
314
|
+
messages: context.session.messages,
|
|
315
|
+
context: procCtx,
|
|
316
|
+
});
|
|
317
|
+
if (outcome.blocked) {
|
|
318
|
+
yield* this.emit(context, {
|
|
319
|
+
type: 'tripwire',
|
|
320
|
+
phase: 'input',
|
|
321
|
+
processorId: outcome.processorId,
|
|
322
|
+
reason: outcome.reason,
|
|
323
|
+
message: outcome.message,
|
|
324
|
+
});
|
|
325
|
+
yield* this.emit(context, { type: 'text-delta', text: outcome.message });
|
|
326
|
+
yield* this.emit(context, { type: 'turn-end' });
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
if (outcome.input !== currentInput) {
|
|
330
|
+
currentInput = outcome.input;
|
|
331
|
+
const last = context.session.messages[context.session.messages.length - 1];
|
|
332
|
+
if (last && last.role === 'user' && typeof last.content === 'string') {
|
|
333
|
+
last.content = currentInput;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
195
337
|
yield* this.emit(context, { type: 'agent-start', agentId: agent.id });
|
|
196
338
|
await this.hookRunner.onAgentStart(context, agent.id);
|
|
197
339
|
let autoContext;
|
|
@@ -201,7 +343,7 @@ export class Runtime {
|
|
|
201
343
|
const callRecord = {
|
|
202
344
|
toolCallId,
|
|
203
345
|
toolName,
|
|
204
|
-
args: { input },
|
|
346
|
+
args: { input: currentInput },
|
|
205
347
|
success: true,
|
|
206
348
|
timestamp: Date.now(),
|
|
207
349
|
};
|
|
@@ -220,7 +362,7 @@ export class Runtime {
|
|
|
220
362
|
});
|
|
221
363
|
let result = null;
|
|
222
364
|
try {
|
|
223
|
-
result = await agent.autoRetrieve.run({ input, context });
|
|
365
|
+
result = await agent.autoRetrieve.run({ input: currentInput, context });
|
|
224
366
|
callRecord.result = result;
|
|
225
367
|
}
|
|
226
368
|
catch (error) {
|
|
@@ -261,7 +403,9 @@ export class Runtime {
|
|
|
261
403
|
}
|
|
262
404
|
const system = this.buildSystemPrompt(agent, injectionQueue, autoContext, context);
|
|
263
405
|
const handoffCandidates = this.getHandoffCandidates(agent);
|
|
264
|
-
const handoffTool =
|
|
406
|
+
const handoffTool = handoffCandidates.length > 0
|
|
407
|
+
? createHandoffTool(handoffCandidates, context.agentId)
|
|
408
|
+
: null;
|
|
265
409
|
let handoffTo = null;
|
|
266
410
|
let handoffReason = 'No reason provided';
|
|
267
411
|
if (this.isFlowAgent(agent)) {
|
|
@@ -280,7 +424,7 @@ export class Runtime {
|
|
|
280
424
|
await this.hookRunner.onStepStart(context, context.stepCount);
|
|
281
425
|
const toolCalls = [];
|
|
282
426
|
try {
|
|
283
|
-
for await (const part of this.runFlowAgent(agent, context,
|
|
427
|
+
for await (const part of this.runFlowAgent(agent, context, currentInput, system, handoffTool ?? undefined, (target, reason) => {
|
|
284
428
|
handoffTo = target;
|
|
285
429
|
handoffReason = reason ?? 'No reason provided';
|
|
286
430
|
}, toolCalls)) {
|
|
@@ -308,7 +452,7 @@ export class Runtime {
|
|
|
308
452
|
}
|
|
309
453
|
}
|
|
310
454
|
else {
|
|
311
|
-
const tools = { ...agent.tools, handoff: handoffTool };
|
|
455
|
+
const tools = this.wrapToolsWithEnforcement(context, handoffTool ? { ...agent.tools, handoff: handoffTool } : { ...agent.tools });
|
|
312
456
|
let agentSteps = 0;
|
|
313
457
|
const agentMaxSteps = agent.maxSteps ?? agent.maxTurns ?? this.maxSteps;
|
|
314
458
|
while (agentSteps < agentMaxSteps) {
|
|
@@ -333,14 +477,14 @@ export class Runtime {
|
|
|
333
477
|
confidence: z.number().min(0).max(1).describe('Routing confidence from 0 to 1.'),
|
|
334
478
|
stayWithCurrent: z.boolean().describe('True only if the current agent is best fit.'),
|
|
335
479
|
});
|
|
336
|
-
|
|
480
|
+
// AI SDK v6+ recommended structured output approach.
|
|
481
|
+
const { output: decision } = await generateText({
|
|
337
482
|
model: model,
|
|
338
|
-
schema,
|
|
483
|
+
output: Output.object({ schema }),
|
|
339
484
|
system: this.buildStructuredTriagePrompt(agent),
|
|
340
485
|
messages: context.session.messages,
|
|
341
486
|
experimental_telemetry: agent.telemetry ?? this.config.telemetry,
|
|
342
487
|
});
|
|
343
|
-
const decision = result.object;
|
|
344
488
|
const allowed = new Set(agent.routes.map(route => route.agentId));
|
|
345
489
|
let target = decision.agentId;
|
|
346
490
|
if (!allowed.has(target)) {
|
|
@@ -362,17 +506,34 @@ export class Runtime {
|
|
|
362
506
|
system,
|
|
363
507
|
messages: context.session.messages,
|
|
364
508
|
tools,
|
|
509
|
+
// Let the AI SDK handle tool-calling steps internally.
|
|
510
|
+
// Default is stepCountIs(1), which ends right after tool-calls.
|
|
511
|
+
stopWhen: stepCountIs(agent.toolMaxSteps ?? 5),
|
|
512
|
+
// Tool execution can read this via options.experimental_context.
|
|
513
|
+
experimental_context: {
|
|
514
|
+
session: context.session,
|
|
515
|
+
agentId: agent.id,
|
|
516
|
+
},
|
|
365
517
|
experimental_telemetry: agent.telemetry ?? this.config.telemetry,
|
|
366
518
|
});
|
|
367
519
|
const toolCalls = [];
|
|
368
520
|
let finalResult = null;
|
|
369
521
|
let finalEmitted = false;
|
|
522
|
+
const outputProcessors = this.getAgentOutputProcessors(agent);
|
|
523
|
+
const bufferOutput = this.outputProcessorMode === 'buffer' && outputProcessors.length > 0;
|
|
524
|
+
let bufferedText = '';
|
|
525
|
+
let stoppedByGuard = false;
|
|
370
526
|
for await (const chunk of result.fullStream) {
|
|
371
527
|
if (chunk.type === 'text-delta') {
|
|
372
528
|
if (finalResult) {
|
|
373
529
|
continue;
|
|
374
530
|
}
|
|
375
|
-
|
|
531
|
+
if (bufferOutput) {
|
|
532
|
+
bufferedText += chunk.text;
|
|
533
|
+
}
|
|
534
|
+
else {
|
|
535
|
+
yield* this.emit(context, { type: 'text-delta', text: chunk.text });
|
|
536
|
+
}
|
|
376
537
|
}
|
|
377
538
|
if (chunk.type === 'tool-call') {
|
|
378
539
|
const args = 'args' in chunk ? chunk.args : chunk.input;
|
|
@@ -402,6 +563,27 @@ export class Runtime {
|
|
|
402
563
|
args,
|
|
403
564
|
});
|
|
404
565
|
}
|
|
566
|
+
if (chunk.type === 'tool-error') {
|
|
567
|
+
const errText = typeof chunk.error === 'string'
|
|
568
|
+
? chunk.error
|
|
569
|
+
: chunk.error?.message ?? 'Tool execution error';
|
|
570
|
+
const args = chunk.input ?? chunk.args;
|
|
571
|
+
const callRecord = toolCalls.find(call => call.toolCallId === chunk.toolCallId);
|
|
572
|
+
if (callRecord) {
|
|
573
|
+
callRecord.success = false;
|
|
574
|
+
callRecord.error = new Error(errText);
|
|
575
|
+
if (args !== undefined)
|
|
576
|
+
callRecord.args = args;
|
|
577
|
+
context.toolCallHistory.push(callRecord);
|
|
578
|
+
await this.hookRunner.onToolError(context, callRecord, callRecord.error);
|
|
579
|
+
}
|
|
580
|
+
yield* this.emit(context, {
|
|
581
|
+
type: 'tool-error',
|
|
582
|
+
toolCallId: chunk.toolCallId,
|
|
583
|
+
toolName: chunk.toolName,
|
|
584
|
+
error: errText,
|
|
585
|
+
});
|
|
586
|
+
}
|
|
405
587
|
if (chunk.type === 'tool-result') {
|
|
406
588
|
const startTime = toolCalls.find(call => call.toolCallId === chunk.toolCallId)?.timestamp ?? Date.now();
|
|
407
589
|
const toolResult = 'result' in chunk ? chunk.result : chunk.output;
|
|
@@ -440,7 +622,12 @@ export class Runtime {
|
|
|
440
622
|
finalResult = { type: 'final', text: reason };
|
|
441
623
|
if (!finalEmitted) {
|
|
442
624
|
finalEmitted = true;
|
|
443
|
-
|
|
625
|
+
if (bufferOutput) {
|
|
626
|
+
bufferedText += reason;
|
|
627
|
+
}
|
|
628
|
+
else {
|
|
629
|
+
yield* this.emit(context, { type: 'text-delta', text: reason });
|
|
630
|
+
}
|
|
444
631
|
}
|
|
445
632
|
continue;
|
|
446
633
|
}
|
|
@@ -451,11 +638,23 @@ export class Runtime {
|
|
|
451
638
|
toolName: chunk.toolName,
|
|
452
639
|
result: toolResult,
|
|
453
640
|
});
|
|
641
|
+
// Stop as soon as a stop condition triggers, even mid-stream.
|
|
642
|
+
const stopResult = checkStopConditions(context, this.stopConditions);
|
|
643
|
+
if (stopResult.shouldStop) {
|
|
644
|
+
yield* this.emit(context, { type: 'error', error: `Stopped: ${stopResult.reason}` });
|
|
645
|
+
stoppedByGuard = true;
|
|
646
|
+
break;
|
|
647
|
+
}
|
|
454
648
|
if (isFinalResult(toolResult)) {
|
|
455
649
|
finalResult = toolResult;
|
|
456
650
|
if (!finalEmitted) {
|
|
457
651
|
finalEmitted = true;
|
|
458
|
-
|
|
652
|
+
if (bufferOutput) {
|
|
653
|
+
bufferedText += toolResult.text;
|
|
654
|
+
}
|
|
655
|
+
else {
|
|
656
|
+
yield* this.emit(context, { type: 'text-delta', text: toolResult.text });
|
|
657
|
+
}
|
|
459
658
|
}
|
|
460
659
|
continue;
|
|
461
660
|
}
|
|
@@ -466,12 +665,45 @@ export class Runtime {
|
|
|
466
665
|
}
|
|
467
666
|
}
|
|
468
667
|
}
|
|
668
|
+
if (stoppedByGuard) {
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
469
671
|
const response = await result.response;
|
|
470
|
-
|
|
471
|
-
|
|
672
|
+
const finishReason = finalResult ? 'final' : await result.finishReason;
|
|
673
|
+
// Only finalize buffered output when the model has completed a non-tool finish.
|
|
674
|
+
// If the model ended on tool-calls, we must persist the tool messages and
|
|
675
|
+
// continue the loop (or let AI SDK continue in maxSteps) instead of emitting
|
|
676
|
+
// synthetic assistant text, which can duplicate responses.
|
|
677
|
+
if (finalResult || (bufferOutput && finishReason !== 'tool-calls')) {
|
|
678
|
+
const rawText = finalResult ? finalResult.text : bufferedText;
|
|
679
|
+
const processed = await this.runOutputProcessing(agent, context, rawText);
|
|
680
|
+
if (processed.tripwire) {
|
|
681
|
+
yield* this.emit(context, {
|
|
682
|
+
type: 'tripwire',
|
|
683
|
+
phase: 'output',
|
|
684
|
+
processorId: processed.tripwire.processorId,
|
|
685
|
+
reason: processed.tripwire.reason,
|
|
686
|
+
message: processed.tripwire.message,
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
if (bufferOutput) {
|
|
690
|
+
yield* this.emit(context, { type: 'text-delta', text: processed.text });
|
|
691
|
+
}
|
|
692
|
+
context.session.messages.push({ role: 'assistant', content: processed.text });
|
|
472
693
|
}
|
|
473
694
|
else {
|
|
695
|
+
const beforeLen = context.session.messages.length;
|
|
474
696
|
context.session.messages.push(...response.messages);
|
|
697
|
+
const tripwires = await this.postProcessPersistedAssistantMessages(agent, context, beforeLen);
|
|
698
|
+
for (const t of tripwires) {
|
|
699
|
+
yield* this.emit(context, {
|
|
700
|
+
type: 'tripwire',
|
|
701
|
+
phase: 'output',
|
|
702
|
+
processorId: t.processorId,
|
|
703
|
+
reason: t.reason,
|
|
704
|
+
message: t.message,
|
|
705
|
+
});
|
|
706
|
+
}
|
|
475
707
|
}
|
|
476
708
|
const usage = await result.usage;
|
|
477
709
|
const totalTokens = usage.totalTokens ?? 0;
|
|
@@ -481,7 +713,6 @@ export class Runtime {
|
|
|
481
713
|
context.session.metadata.totalSteps += 1;
|
|
482
714
|
}
|
|
483
715
|
context.consecutiveErrors = 0;
|
|
484
|
-
const finishReason = finalResult ? 'final' : await result.finishReason;
|
|
485
716
|
yield* this.emit(context, { type: 'step-end', step: context.stepCount, agentId: agent.id });
|
|
486
717
|
await this.hookRunner.onStepEnd(context, context.stepCount, {
|
|
487
718
|
toolCalls,
|
|
@@ -623,7 +854,14 @@ ${routeDescriptions}
|
|
|
623
854
|
.map(route => this.agents.get(route.agentId))
|
|
624
855
|
.filter((candidate) => Boolean(candidate));
|
|
625
856
|
}
|
|
626
|
-
|
|
857
|
+
// Production default: only triage routes by default. Other agents can handoff only if explicitly configured.
|
|
858
|
+
const targets = agent.canHandoffTo;
|
|
859
|
+
if (!targets || targets.length === 0) {
|
|
860
|
+
return [];
|
|
861
|
+
}
|
|
862
|
+
return targets
|
|
863
|
+
.map(id => this.agents.get(id))
|
|
864
|
+
.filter((candidate) => Boolean(candidate));
|
|
627
865
|
}
|
|
628
866
|
getFlowState(session, agentId) {
|
|
629
867
|
const stored = session.agentStates?.[agentId]?.state;
|
|
@@ -643,20 +881,40 @@ ${routeDescriptions}
|
|
|
643
881
|
delete session.agentStates[agentId];
|
|
644
882
|
}
|
|
645
883
|
buildFlowWithHandoff(agent, handoffTool, suppressAutoRespond) {
|
|
884
|
+
// If we don't need to inject a handoff tool and we don't need to suppress the initial autoRespond,
|
|
885
|
+
// we can return the flow as-is. Otherwise we must clone nodes to apply changes.
|
|
886
|
+
const needsInitialSuppression = suppressAutoRespond &&
|
|
887
|
+
agent.flow.nodes.some(n => n.id === agent.initialNode && n.autoRespond === undefined);
|
|
888
|
+
if (!handoffTool && !needsInitialSuppression)
|
|
889
|
+
return agent.flow;
|
|
646
890
|
return {
|
|
647
891
|
...agent.flow,
|
|
648
892
|
nodes: agent.flow.nodes.map(node => {
|
|
649
893
|
const shouldSuppress = suppressAutoRespond
|
|
650
894
|
&& node.id === agent.initialNode
|
|
651
895
|
&& node.autoRespond === undefined;
|
|
652
|
-
const existingTools = node.tools
|
|
653
|
-
|
|
896
|
+
const existingTools = node.tools;
|
|
897
|
+
// Support tool factories (tools: (ctx) => ToolSet) as well as static ToolSets.
|
|
898
|
+
if (typeof existingTools === 'function') {
|
|
899
|
+
return {
|
|
900
|
+
...node,
|
|
901
|
+
tools: (ctx) => {
|
|
902
|
+
const resolved = existingTools(ctx) ?? {};
|
|
903
|
+
if (!handoffTool)
|
|
904
|
+
return resolved;
|
|
905
|
+
return resolved.handoff ? resolved : { ...resolved, handoff: handoffTool };
|
|
906
|
+
},
|
|
907
|
+
...(shouldSuppress ? { autoRespond: false } : {}),
|
|
908
|
+
};
|
|
909
|
+
}
|
|
910
|
+
const toolSet = existingTools ?? {};
|
|
911
|
+
if (!handoffTool || toolSet.handoff) {
|
|
654
912
|
return shouldSuppress ? { ...node, autoRespond: false } : node;
|
|
655
913
|
}
|
|
656
914
|
return {
|
|
657
915
|
...node,
|
|
658
916
|
tools: {
|
|
659
|
-
...
|
|
917
|
+
...toolSet,
|
|
660
918
|
handoff: handoffTool,
|
|
661
919
|
},
|
|
662
920
|
...(shouldSuppress ? { autoRespond: false } : {}),
|
|
@@ -671,6 +929,12 @@ ${routeDescriptions}
|
|
|
671
929
|
return agent.flow.nodes.find(node => node.id === nodeId);
|
|
672
930
|
}
|
|
673
931
|
async shouldHandleFlowInput(agent, input, flowState) {
|
|
932
|
+
// Before the flow is initialized, be conservative: run the flow.
|
|
933
|
+
// This avoids misrouting the very first user message after a handoff into "detour",
|
|
934
|
+
// which can lead to unnecessary specialist handoffs and handoff loops.
|
|
935
|
+
if (!flowState?.initialized) {
|
|
936
|
+
return true;
|
|
937
|
+
}
|
|
674
938
|
const nodeId = flowState?.context.currentNode ?? agent.initialNode;
|
|
675
939
|
const node = this.getFlowNode(agent, nodeId);
|
|
676
940
|
if (!node) {
|
|
@@ -695,11 +959,12 @@ ${routeDescriptions}
|
|
|
695
959
|
}
|
|
696
960
|
}
|
|
697
961
|
const collectedData = flowState?.context.collectedData ?? {};
|
|
962
|
+
const renderedNodePrompt = renderFlowTemplate(node.prompt, collectedData, { missing: 'keep' });
|
|
698
963
|
const routerPrompt = `You are routing a message for a structured conversation flow.
|
|
699
964
|
Decide if the user input is answering the current flow step or is a side question.
|
|
700
965
|
|
|
701
966
|
Current flow step:
|
|
702
|
-
${
|
|
967
|
+
${renderedNodePrompt}
|
|
703
968
|
|
|
704
969
|
Collected data:
|
|
705
970
|
${JSON.stringify(collectedData, null, 2)}
|
|
@@ -742,10 +1007,15 @@ Return only one word: "flow" if the input should be handled by the flow step, or
|
|
|
742
1007
|
async *runDetourResponse(agent, context, input, flowState, handoffTool, onHandoff) {
|
|
743
1008
|
const nodeId = flowState?.context.currentNode ?? agent.initialNode;
|
|
744
1009
|
const node = this.getFlowNode(agent, nodeId);
|
|
745
|
-
const nodePrompt = node?.prompt ?? 'Continue the flow.';
|
|
746
1010
|
const collectedData = flowState?.context.collectedData ?? {};
|
|
1011
|
+
const nodePrompt = node
|
|
1012
|
+
? renderFlowTemplate(node.prompt, collectedData, { missing: 'keep' })
|
|
1013
|
+
: 'Continue the flow.';
|
|
1014
|
+
const handoffLine = handoffTool
|
|
1015
|
+
? 'If the request should be handled by a specialist agent, use the handoff tool instead of answering.'
|
|
1016
|
+
: 'Do not attempt to route to other agents. Handle the detour here and then resume the flow.';
|
|
747
1017
|
const detourPrompt = `You are handling a short detour during a structured flow.
|
|
748
|
-
Answer the user's question clearly and briefly.
|
|
1018
|
+
Answer the user's question clearly and briefly. ${handoffLine} Then ask the user to continue with the current flow step.
|
|
749
1019
|
|
|
750
1020
|
Current flow step:
|
|
751
1021
|
${nodePrompt}
|
|
@@ -758,16 +1028,24 @@ Do not change the flow requirements. Keep the reply concise.`;
|
|
|
758
1028
|
model: (agent.model ?? this.defaultModel),
|
|
759
1029
|
system: detourPrompt,
|
|
760
1030
|
messages: context.session.messages,
|
|
761
|
-
tools: { handoff: handoffTool },
|
|
1031
|
+
tools: handoffTool ? { handoff: handoffTool } : undefined,
|
|
762
1032
|
experimental_telemetry: agent.telemetry ?? this.config.telemetry,
|
|
763
1033
|
});
|
|
764
1034
|
let handoffTriggered = false;
|
|
1035
|
+
const outputProcessors = this.getAgentOutputProcessors(agent);
|
|
1036
|
+
const bufferOutput = this.outputProcessorMode === 'buffer' && outputProcessors.length > 0;
|
|
1037
|
+
let bufferedText = '';
|
|
765
1038
|
for await (const chunk of result.fullStream) {
|
|
766
1039
|
if (chunk.type === 'text-delta') {
|
|
767
1040
|
if (handoffTriggered) {
|
|
768
1041
|
continue;
|
|
769
1042
|
}
|
|
770
|
-
|
|
1043
|
+
if (bufferOutput) {
|
|
1044
|
+
bufferedText += chunk.text;
|
|
1045
|
+
}
|
|
1046
|
+
else {
|
|
1047
|
+
yield* this.emit(context, { type: 'text-delta', text: chunk.text });
|
|
1048
|
+
}
|
|
771
1049
|
}
|
|
772
1050
|
if (chunk.type === 'tool-call') {
|
|
773
1051
|
const args = 'args' in chunk ? chunk.args : chunk.input;
|
|
@@ -795,7 +1073,34 @@ Do not change the flow requirements. Keep the reply concise.`;
|
|
|
795
1073
|
}
|
|
796
1074
|
}
|
|
797
1075
|
const response = await result.response;
|
|
798
|
-
|
|
1076
|
+
if (bufferOutput) {
|
|
1077
|
+
const processed = await this.runOutputProcessing(agent, context, bufferedText);
|
|
1078
|
+
if (processed.tripwire) {
|
|
1079
|
+
yield* this.emit(context, {
|
|
1080
|
+
type: 'tripwire',
|
|
1081
|
+
phase: 'output',
|
|
1082
|
+
processorId: processed.tripwire.processorId,
|
|
1083
|
+
reason: processed.tripwire.reason,
|
|
1084
|
+
message: processed.tripwire.message,
|
|
1085
|
+
});
|
|
1086
|
+
}
|
|
1087
|
+
yield* this.emit(context, { type: 'text-delta', text: processed.text });
|
|
1088
|
+
context.session.messages.push({ role: 'assistant', content: processed.text });
|
|
1089
|
+
}
|
|
1090
|
+
else {
|
|
1091
|
+
const beforeLen = context.session.messages.length;
|
|
1092
|
+
context.session.messages.push(...response.messages);
|
|
1093
|
+
const tripwires = await this.postProcessPersistedAssistantMessages(agent, context, beforeLen);
|
|
1094
|
+
for (const t of tripwires) {
|
|
1095
|
+
yield* this.emit(context, {
|
|
1096
|
+
type: 'tripwire',
|
|
1097
|
+
phase: 'output',
|
|
1098
|
+
processorId: t.processorId,
|
|
1099
|
+
reason: t.reason,
|
|
1100
|
+
message: t.message,
|
|
1101
|
+
});
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
799
1104
|
const usage = await result.usage;
|
|
800
1105
|
const totalTokens = usage.totalTokens ?? 0;
|
|
801
1106
|
context.totalTokens += totalTokens;
|
|
@@ -816,8 +1121,18 @@ Do not change the flow requirements. Keep the reply concise.`;
|
|
|
816
1121
|
if (detourRules?.emergency && this.matchesDetourRule(input, detourRules.emergency)) {
|
|
817
1122
|
const emergencyText = detourRules.emergencyMessage
|
|
818
1123
|
?? 'This sounds urgent. Please call local emergency services immediately or go to the nearest emergency room.';
|
|
819
|
-
|
|
820
|
-
|
|
1124
|
+
const processed = await this.runOutputProcessing(agent, context, emergencyText);
|
|
1125
|
+
if (processed.tripwire) {
|
|
1126
|
+
yield* this.emit(context, {
|
|
1127
|
+
type: 'tripwire',
|
|
1128
|
+
phase: 'output',
|
|
1129
|
+
processorId: processed.tripwire.processorId,
|
|
1130
|
+
reason: processed.tripwire.reason,
|
|
1131
|
+
message: processed.tripwire.message,
|
|
1132
|
+
});
|
|
1133
|
+
}
|
|
1134
|
+
context.session.messages.push({ role: 'assistant', content: processed.text });
|
|
1135
|
+
yield* this.emit(context, { type: 'text-delta', text: processed.text });
|
|
821
1136
|
yield* this.emit(context, { type: 'turn-end' });
|
|
822
1137
|
if (detourRules.emergencyHandoffAgent) {
|
|
823
1138
|
onHandoff(detourRules.emergencyHandoffAgent, 'Emergency pattern matched');
|
|
@@ -843,7 +1158,26 @@ Do not change the flow requirements. Keep the reply concise.`;
|
|
|
843
1158
|
sessionMessages: context.session.messages,
|
|
844
1159
|
state: flowState,
|
|
845
1160
|
telemetry: agent.telemetry ?? this.config.telemetry,
|
|
1161
|
+
toolCallGuard: async ({ toolName, args }) => {
|
|
1162
|
+
const callRecord = {
|
|
1163
|
+
toolCallId: crypto.randomUUID(),
|
|
1164
|
+
toolName,
|
|
1165
|
+
args,
|
|
1166
|
+
success: true,
|
|
1167
|
+
timestamp: Date.now(),
|
|
1168
|
+
};
|
|
1169
|
+
const enforcement = await this.enforcer.check(callRecord, {
|
|
1170
|
+
previousCalls: context.toolCallHistory,
|
|
1171
|
+
currentStep: context.stepCount,
|
|
1172
|
+
sessionState: context.session.state ?? {},
|
|
1173
|
+
});
|
|
1174
|
+
if (!enforcement.allowed) {
|
|
1175
|
+
return { allowed: false, reason: enforcement.reason ?? 'Tool call blocked by enforcement' };
|
|
1176
|
+
}
|
|
1177
|
+
return { allowed: true };
|
|
1178
|
+
},
|
|
846
1179
|
});
|
|
1180
|
+
let persistedStartIndex = context.session.messages.length;
|
|
847
1181
|
if (!flowState?.initialized) {
|
|
848
1182
|
for await (const part of flowManager.initialize()) {
|
|
849
1183
|
switch (part.type) {
|
|
@@ -907,6 +1241,26 @@ Do not change the flow requirements. Keep the reply concise.`;
|
|
|
907
1241
|
});
|
|
908
1242
|
break;
|
|
909
1243
|
}
|
|
1244
|
+
case 'tool-error': {
|
|
1245
|
+
const toolCallId = part.toolCallId
|
|
1246
|
+
?? toolCalls.find(call => call.toolName === part.toolName && call.error === undefined)?.toolCallId
|
|
1247
|
+
?? crypto.randomUUID();
|
|
1248
|
+
const callRecord = toolCalls.find(call => call.toolCallId === toolCallId);
|
|
1249
|
+
if (callRecord) {
|
|
1250
|
+
callRecord.success = false;
|
|
1251
|
+
callRecord.error = new Error(part.error);
|
|
1252
|
+
callRecord.durationMs = Date.now() - callRecord.timestamp;
|
|
1253
|
+
context.toolCallHistory.push(callRecord);
|
|
1254
|
+
await this.hookRunner.onToolError(context, callRecord, callRecord.error);
|
|
1255
|
+
}
|
|
1256
|
+
yield* this.emit(context, {
|
|
1257
|
+
type: 'tool-error',
|
|
1258
|
+
toolCallId,
|
|
1259
|
+
toolName: part.toolName,
|
|
1260
|
+
error: part.error,
|
|
1261
|
+
});
|
|
1262
|
+
break;
|
|
1263
|
+
}
|
|
910
1264
|
case 'handoff':
|
|
911
1265
|
onHandoff(part.targetAgent, part.reason);
|
|
912
1266
|
break;
|
|
@@ -932,6 +1286,18 @@ Do not change the flow requirements. Keep the reply concise.`;
|
|
|
932
1286
|
break;
|
|
933
1287
|
}
|
|
934
1288
|
}
|
|
1289
|
+
// Sanitize/redact anything FlowManager persisted during initialization.
|
|
1290
|
+
const tripwires = await this.postProcessPersistedAssistantMessages(agent, context, persistedStartIndex);
|
|
1291
|
+
for (const t of tripwires) {
|
|
1292
|
+
yield* this.emit(context, {
|
|
1293
|
+
type: 'tripwire',
|
|
1294
|
+
phase: 'output',
|
|
1295
|
+
processorId: t.processorId,
|
|
1296
|
+
reason: t.reason,
|
|
1297
|
+
message: t.message,
|
|
1298
|
+
});
|
|
1299
|
+
}
|
|
1300
|
+
persistedStartIndex = context.session.messages.length;
|
|
935
1301
|
}
|
|
936
1302
|
for await (const part of flowManager.process(input, { appendUserToSession: false })) {
|
|
937
1303
|
switch (part.type) {
|
|
@@ -995,6 +1361,26 @@ Do not change the flow requirements. Keep the reply concise.`;
|
|
|
995
1361
|
});
|
|
996
1362
|
break;
|
|
997
1363
|
}
|
|
1364
|
+
case 'tool-error': {
|
|
1365
|
+
const toolCallId = part.toolCallId
|
|
1366
|
+
?? toolCalls.find(call => call.toolName === part.toolName && call.error === undefined)?.toolCallId
|
|
1367
|
+
?? crypto.randomUUID();
|
|
1368
|
+
const callRecord = toolCalls.find(call => call.toolCallId === toolCallId);
|
|
1369
|
+
if (callRecord) {
|
|
1370
|
+
callRecord.success = false;
|
|
1371
|
+
callRecord.error = new Error(part.error);
|
|
1372
|
+
callRecord.durationMs = Date.now() - callRecord.timestamp;
|
|
1373
|
+
context.toolCallHistory.push(callRecord);
|
|
1374
|
+
await this.hookRunner.onToolError(context, callRecord, callRecord.error);
|
|
1375
|
+
}
|
|
1376
|
+
yield* this.emit(context, {
|
|
1377
|
+
type: 'tool-error',
|
|
1378
|
+
toolCallId,
|
|
1379
|
+
toolName: part.toolName,
|
|
1380
|
+
error: part.error,
|
|
1381
|
+
});
|
|
1382
|
+
break;
|
|
1383
|
+
}
|
|
998
1384
|
case 'handoff':
|
|
999
1385
|
onHandoff(part.targetAgent, part.reason);
|
|
1000
1386
|
break;
|
|
@@ -1020,6 +1406,17 @@ Do not change the flow requirements. Keep the reply concise.`;
|
|
|
1020
1406
|
break;
|
|
1021
1407
|
}
|
|
1022
1408
|
}
|
|
1409
|
+
// Sanitize/redact anything FlowManager persisted during this user turn.
|
|
1410
|
+
const tripwires = await this.postProcessPersistedAssistantMessages(agent, context, persistedStartIndex);
|
|
1411
|
+
for (const t of tripwires) {
|
|
1412
|
+
yield* this.emit(context, {
|
|
1413
|
+
type: 'tripwire',
|
|
1414
|
+
phase: 'output',
|
|
1415
|
+
processorId: t.processorId,
|
|
1416
|
+
reason: t.reason,
|
|
1417
|
+
message: t.message,
|
|
1418
|
+
});
|
|
1419
|
+
}
|
|
1023
1420
|
if (flowManager.hasEnded) {
|
|
1024
1421
|
this.clearFlowState(context.session, agent.id);
|
|
1025
1422
|
}
|
|
@@ -1039,10 +1436,112 @@ Do not change the flow requirements. Keep the reply concise.`;
|
|
|
1039
1436
|
getAllAgents() {
|
|
1040
1437
|
return Array.from(this.agents.values());
|
|
1041
1438
|
}
|
|
1439
|
+
buildProcessorContext(context) {
|
|
1440
|
+
return {
|
|
1441
|
+
session: context.session,
|
|
1442
|
+
agentId: context.agentId,
|
|
1443
|
+
toolCallHistory: context.toolCallHistory,
|
|
1444
|
+
};
|
|
1445
|
+
}
|
|
1446
|
+
getAgentOutputProcessors(agent) {
|
|
1447
|
+
return [
|
|
1448
|
+
...this.outputProcessors,
|
|
1449
|
+
...(agent.outputProcessors ?? []),
|
|
1450
|
+
];
|
|
1451
|
+
}
|
|
1452
|
+
applyRedactionsToText(text) {
|
|
1453
|
+
if (!this.outputRedactions || this.outputRedactions.length === 0)
|
|
1454
|
+
return text;
|
|
1455
|
+
let out = text;
|
|
1456
|
+
for (const r of this.outputRedactions) {
|
|
1457
|
+
out = out.replace(r.re, r.replacement);
|
|
1458
|
+
}
|
|
1459
|
+
return out;
|
|
1460
|
+
}
|
|
1461
|
+
async runOutputProcessing(agent, context, text) {
|
|
1462
|
+
const processors = this.getAgentOutputProcessors(agent);
|
|
1463
|
+
let cur = text;
|
|
1464
|
+
if (processors.length > 0) {
|
|
1465
|
+
const outcome = await runOutputProcessors({
|
|
1466
|
+
processors,
|
|
1467
|
+
text: cur,
|
|
1468
|
+
messages: context.session.messages,
|
|
1469
|
+
context: this.buildProcessorContext(context),
|
|
1470
|
+
});
|
|
1471
|
+
if (outcome.blocked) {
|
|
1472
|
+
const msg = this.applyRedactionsToText(outcome.message);
|
|
1473
|
+
return {
|
|
1474
|
+
text: msg,
|
|
1475
|
+
tripwire: { processorId: outcome.processorId, reason: outcome.reason, message: msg },
|
|
1476
|
+
};
|
|
1477
|
+
}
|
|
1478
|
+
cur = outcome.text;
|
|
1479
|
+
}
|
|
1480
|
+
cur = this.applyRedactionsToText(cur);
|
|
1481
|
+
return { text: cur };
|
|
1482
|
+
}
|
|
1483
|
+
async postProcessPersistedAssistantMessages(agent, context, startIndex) {
|
|
1484
|
+
const tripwires = [];
|
|
1485
|
+
const msgs = context.session.messages;
|
|
1486
|
+
for (let i = startIndex; i < msgs.length; i++) {
|
|
1487
|
+
const m = msgs[i];
|
|
1488
|
+
if (!m || m.role !== 'assistant' || typeof m.content !== 'string')
|
|
1489
|
+
continue;
|
|
1490
|
+
const res = await this.runOutputProcessing(agent, context, m.content);
|
|
1491
|
+
msgs[i] = { ...m, content: res.text };
|
|
1492
|
+
if (res.tripwire) {
|
|
1493
|
+
tripwires.push(res.tripwire);
|
|
1494
|
+
}
|
|
1495
|
+
}
|
|
1496
|
+
return tripwires;
|
|
1497
|
+
}
|
|
1042
1498
|
async *emit(context, part) {
|
|
1499
|
+
// Defense-in-depth redaction of streamed assistant output.
|
|
1500
|
+
if (this.outputRedactions && this.outputRedactions.length > 0) {
|
|
1501
|
+
const sessionId = context.session.id;
|
|
1502
|
+
if (part.type === 'text-delta') {
|
|
1503
|
+
const next = this.applyOutputRedactions(sessionId, part.text, false);
|
|
1504
|
+
if (next) {
|
|
1505
|
+
const redacted = { ...part, text: next };
|
|
1506
|
+
await this.hookRunner.onStreamPart(context, redacted);
|
|
1507
|
+
yield redacted;
|
|
1508
|
+
}
|
|
1509
|
+
return;
|
|
1510
|
+
}
|
|
1511
|
+
if (part.type === 'turn-end' || part.type === 'done') {
|
|
1512
|
+
const flushed = this.applyOutputRedactions(sessionId, '', true);
|
|
1513
|
+
if (flushed) {
|
|
1514
|
+
const carryPart = { type: 'text-delta', text: flushed };
|
|
1515
|
+
await this.hookRunner.onStreamPart(context, carryPart);
|
|
1516
|
+
yield carryPart;
|
|
1517
|
+
}
|
|
1518
|
+
this.redactCarryBySession.delete(sessionId);
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1043
1521
|
await this.hookRunner.onStreamPart(context, part);
|
|
1044
1522
|
yield part;
|
|
1045
1523
|
}
|
|
1524
|
+
applyOutputRedactions(sessionId, text, flush) {
|
|
1525
|
+
if (!this.outputRedactions || this.outputRedactions.length === 0)
|
|
1526
|
+
return text;
|
|
1527
|
+
const carry = this.redactCarryBySession.get(sessionId) ?? '';
|
|
1528
|
+
let combined = `${carry}${text}`;
|
|
1529
|
+
for (const r of this.outputRedactions) {
|
|
1530
|
+
combined = combined.replace(r.re, r.replacement);
|
|
1531
|
+
}
|
|
1532
|
+
const keep = flush ? 0 : this.redactLookbehind;
|
|
1533
|
+
if (keep === 0) {
|
|
1534
|
+
this.redactCarryBySession.set(sessionId, '');
|
|
1535
|
+
return combined;
|
|
1536
|
+
}
|
|
1537
|
+
if (combined.length <= keep) {
|
|
1538
|
+
this.redactCarryBySession.set(sessionId, combined);
|
|
1539
|
+
return '';
|
|
1540
|
+
}
|
|
1541
|
+
const out = combined.slice(0, combined.length - keep);
|
|
1542
|
+
this.redactCarryBySession.set(sessionId, combined.slice(-keep));
|
|
1543
|
+
return out;
|
|
1544
|
+
}
|
|
1046
1545
|
}
|
|
1047
1546
|
export function createRuntime(config) {
|
|
1048
1547
|
return new Runtime(config);
|