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