@ariaflowagents/core 0.5.0 → 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/agents/TriageAgent.d.ts.map +1 -1
- package/dist/agents/TriageAgent.js +3 -2
- package/dist/agents/TriageAgent.js.map +1 -1
- package/dist/callbacks/httpCallback.js +5 -5
- package/dist/callbacks/httpCallback.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 +70 -4
- package/dist/flows/AgentFlowManager.js.map +1 -1
- package/dist/flows/FlowManager.d.ts +24 -3
- package/dist/flows/FlowManager.d.ts.map +1 -1
- package/dist/flows/FlowManager.js +195 -14
- 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 +12 -0
- package/dist/flows/transitions.d.ts.map +1 -1
- package/dist/flows/transitions.js +23 -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 +14 -0
- package/dist/runtime/Runtime.d.ts.map +1 -1
- package/dist/runtime/Runtime.js +649 -65
- 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 +137 -2
- 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,
|
|
@@ -130,7 +172,11 @@ export class Runtime {
|
|
|
130
172
|
const injectionQueue = new InjectionQueue();
|
|
131
173
|
injectionQueue.add(commonInjections.professional);
|
|
132
174
|
injectionQueue.add(commonInjections.noGuessing);
|
|
175
|
+
injectionQueue.add(commonInjections.noSecrets);
|
|
176
|
+
injectionQueue.add(commonInjections.invisibleHandoffs);
|
|
133
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 });
|
|
134
180
|
const controller = new AbortController();
|
|
135
181
|
this.abortControllers.set(session.id, controller);
|
|
136
182
|
const abortHandler = () => {
|
|
@@ -144,8 +190,64 @@ export class Runtime {
|
|
|
144
190
|
abortSignal.addEventListener('abort', externalAbortHandler);
|
|
145
191
|
}
|
|
146
192
|
await this.hookRunner.onStart(context);
|
|
193
|
+
// Run input processors BEFORE persisting the user message.
|
|
194
|
+
let processedInput = input;
|
|
147
195
|
try {
|
|
148
|
-
|
|
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);
|
|
149
251
|
await this.hookRunner.onEnd(context, { success: true });
|
|
150
252
|
}
|
|
151
253
|
catch (error) {
|
|
@@ -164,6 +266,7 @@ export class Runtime {
|
|
|
164
266
|
yield* this.stream({ input, sessionId, userId });
|
|
165
267
|
}
|
|
166
268
|
async *runLoop(context, injectionQueue, input, abortController) {
|
|
269
|
+
let currentInput = input;
|
|
167
270
|
while (context.handoffStack.length < this.maxHandoffs) {
|
|
168
271
|
if (abortController?.signal.aborted) {
|
|
169
272
|
yield* this.emit(context, {
|
|
@@ -176,11 +279,16 @@ export class Runtime {
|
|
|
176
279
|
});
|
|
177
280
|
return;
|
|
178
281
|
}
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
}
|
|
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 });
|
|
184
292
|
return;
|
|
185
293
|
}
|
|
186
294
|
context.handoffStack.push(context.agentId);
|
|
@@ -189,6 +297,40 @@ export class Runtime {
|
|
|
189
297
|
yield* this.emit(context, { type: 'error', error: `Agent "${context.agentId}" not found` });
|
|
190
298
|
return;
|
|
191
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
|
+
}
|
|
192
334
|
yield* this.emit(context, { type: 'agent-start', agentId: agent.id });
|
|
193
335
|
await this.hookRunner.onAgentStart(context, agent.id);
|
|
194
336
|
let autoContext;
|
|
@@ -198,7 +340,7 @@ export class Runtime {
|
|
|
198
340
|
const callRecord = {
|
|
199
341
|
toolCallId,
|
|
200
342
|
toolName,
|
|
201
|
-
args: { input },
|
|
343
|
+
args: { input: currentInput },
|
|
202
344
|
success: true,
|
|
203
345
|
timestamp: Date.now(),
|
|
204
346
|
};
|
|
@@ -217,7 +359,7 @@ export class Runtime {
|
|
|
217
359
|
});
|
|
218
360
|
let result = null;
|
|
219
361
|
try {
|
|
220
|
-
result = await agent.autoRetrieve.run({ input, context });
|
|
362
|
+
result = await agent.autoRetrieve.run({ input: currentInput, context });
|
|
221
363
|
callRecord.result = result;
|
|
222
364
|
}
|
|
223
365
|
catch (error) {
|
|
@@ -258,7 +400,9 @@ export class Runtime {
|
|
|
258
400
|
}
|
|
259
401
|
const system = this.buildSystemPrompt(agent, injectionQueue, autoContext, context);
|
|
260
402
|
const handoffCandidates = this.getHandoffCandidates(agent);
|
|
261
|
-
const handoffTool =
|
|
403
|
+
const handoffTool = handoffCandidates.length > 0
|
|
404
|
+
? createHandoffTool(handoffCandidates, context.agentId)
|
|
405
|
+
: null;
|
|
262
406
|
let handoffTo = null;
|
|
263
407
|
let handoffReason = 'No reason provided';
|
|
264
408
|
if (this.isFlowAgent(agent)) {
|
|
@@ -277,7 +421,7 @@ export class Runtime {
|
|
|
277
421
|
await this.hookRunner.onStepStart(context, context.stepCount);
|
|
278
422
|
const toolCalls = [];
|
|
279
423
|
try {
|
|
280
|
-
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) => {
|
|
281
425
|
handoffTo = target;
|
|
282
426
|
handoffReason = reason ?? 'No reason provided';
|
|
283
427
|
}, toolCalls)) {
|
|
@@ -305,7 +449,7 @@ export class Runtime {
|
|
|
305
449
|
}
|
|
306
450
|
}
|
|
307
451
|
else {
|
|
308
|
-
const tools = { ...agent.tools, handoff: handoffTool };
|
|
452
|
+
const tools = this.wrapToolsWithEnforcement(context, handoffTool ? { ...agent.tools, handoff: handoffTool } : { ...agent.tools });
|
|
309
453
|
let agentSteps = 0;
|
|
310
454
|
const agentMaxSteps = agent.maxSteps ?? agent.maxTurns ?? this.maxSteps;
|
|
311
455
|
while (agentSteps < agentMaxSteps) {
|
|
@@ -330,13 +474,14 @@ export class Runtime {
|
|
|
330
474
|
confidence: z.number().min(0).max(1).describe('Routing confidence from 0 to 1.'),
|
|
331
475
|
stayWithCurrent: z.boolean().describe('True only if the current agent is best fit.'),
|
|
332
476
|
});
|
|
333
|
-
|
|
477
|
+
// AI SDK v6+ recommended structured output approach.
|
|
478
|
+
const { output: decision } = await generateText({
|
|
334
479
|
model: model,
|
|
335
|
-
schema,
|
|
480
|
+
output: Output.object({ schema }),
|
|
336
481
|
system: this.buildStructuredTriagePrompt(agent),
|
|
337
482
|
messages: context.session.messages,
|
|
483
|
+
experimental_telemetry: agent.telemetry ?? this.config.telemetry,
|
|
338
484
|
});
|
|
339
|
-
const decision = result.object;
|
|
340
485
|
const allowed = new Set(agent.routes.map(route => route.agentId));
|
|
341
486
|
let target = decision.agentId;
|
|
342
487
|
if (!allowed.has(target)) {
|
|
@@ -358,16 +503,34 @@ export class Runtime {
|
|
|
358
503
|
system,
|
|
359
504
|
messages: context.session.messages,
|
|
360
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
|
+
},
|
|
514
|
+
experimental_telemetry: agent.telemetry ?? this.config.telemetry,
|
|
361
515
|
});
|
|
362
516
|
const toolCalls = [];
|
|
363
517
|
let finalResult = null;
|
|
364
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;
|
|
365
523
|
for await (const chunk of result.fullStream) {
|
|
366
524
|
if (chunk.type === 'text-delta') {
|
|
367
525
|
if (finalResult) {
|
|
368
526
|
continue;
|
|
369
527
|
}
|
|
370
|
-
|
|
528
|
+
if (bufferOutput) {
|
|
529
|
+
bufferedText += chunk.text;
|
|
530
|
+
}
|
|
531
|
+
else {
|
|
532
|
+
yield* this.emit(context, { type: 'text-delta', text: chunk.text });
|
|
533
|
+
}
|
|
371
534
|
}
|
|
372
535
|
if (chunk.type === 'tool-call') {
|
|
373
536
|
const args = 'args' in chunk ? chunk.args : chunk.input;
|
|
@@ -397,6 +560,27 @@ export class Runtime {
|
|
|
397
560
|
args,
|
|
398
561
|
});
|
|
399
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
|
+
}
|
|
400
584
|
if (chunk.type === 'tool-result') {
|
|
401
585
|
const startTime = toolCalls.find(call => call.toolCallId === chunk.toolCallId)?.timestamp ?? Date.now();
|
|
402
586
|
const toolResult = 'result' in chunk ? chunk.result : chunk.output;
|
|
@@ -435,7 +619,12 @@ export class Runtime {
|
|
|
435
619
|
finalResult = { type: 'final', text: reason };
|
|
436
620
|
if (!finalEmitted) {
|
|
437
621
|
finalEmitted = true;
|
|
438
|
-
|
|
622
|
+
if (bufferOutput) {
|
|
623
|
+
bufferedText += reason;
|
|
624
|
+
}
|
|
625
|
+
else {
|
|
626
|
+
yield* this.emit(context, { type: 'text-delta', text: reason });
|
|
627
|
+
}
|
|
439
628
|
}
|
|
440
629
|
continue;
|
|
441
630
|
}
|
|
@@ -446,11 +635,23 @@ export class Runtime {
|
|
|
446
635
|
toolName: chunk.toolName,
|
|
447
636
|
result: toolResult,
|
|
448
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
|
+
}
|
|
449
645
|
if (isFinalResult(toolResult)) {
|
|
450
646
|
finalResult = toolResult;
|
|
451
647
|
if (!finalEmitted) {
|
|
452
648
|
finalEmitted = true;
|
|
453
|
-
|
|
649
|
+
if (bufferOutput) {
|
|
650
|
+
bufferedText += toolResult.text;
|
|
651
|
+
}
|
|
652
|
+
else {
|
|
653
|
+
yield* this.emit(context, { type: 'text-delta', text: toolResult.text });
|
|
654
|
+
}
|
|
454
655
|
}
|
|
455
656
|
continue;
|
|
456
657
|
}
|
|
@@ -461,12 +662,45 @@ export class Runtime {
|
|
|
461
662
|
}
|
|
462
663
|
}
|
|
463
664
|
}
|
|
665
|
+
if (stoppedByGuard) {
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
464
668
|
const response = await result.response;
|
|
465
|
-
|
|
466
|
-
|
|
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 });
|
|
467
690
|
}
|
|
468
691
|
else {
|
|
692
|
+
const beforeLen = context.session.messages.length;
|
|
469
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
|
+
}
|
|
470
704
|
}
|
|
471
705
|
const usage = await result.usage;
|
|
472
706
|
const totalTokens = usage.totalTokens ?? 0;
|
|
@@ -476,7 +710,6 @@ export class Runtime {
|
|
|
476
710
|
context.session.metadata.totalSteps += 1;
|
|
477
711
|
}
|
|
478
712
|
context.consecutiveErrors = 0;
|
|
479
|
-
const finishReason = finalResult ? 'final' : await result.finishReason;
|
|
480
713
|
yield* this.emit(context, { type: 'step-end', step: context.stepCount, agentId: agent.id });
|
|
481
714
|
await this.hookRunner.onStepEnd(context, context.stepCount, {
|
|
482
715
|
toolCalls,
|
|
@@ -618,7 +851,14 @@ ${routeDescriptions}
|
|
|
618
851
|
.map(route => this.agents.get(route.agentId))
|
|
619
852
|
.filter((candidate) => Boolean(candidate));
|
|
620
853
|
}
|
|
621
|
-
|
|
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));
|
|
622
862
|
}
|
|
623
863
|
getFlowState(session, agentId) {
|
|
624
864
|
const stored = session.agentStates?.[agentId]?.state;
|
|
@@ -638,20 +878,40 @@ ${routeDescriptions}
|
|
|
638
878
|
delete session.agentStates[agentId];
|
|
639
879
|
}
|
|
640
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;
|
|
641
887
|
return {
|
|
642
888
|
...agent.flow,
|
|
643
889
|
nodes: agent.flow.nodes.map(node => {
|
|
644
890
|
const shouldSuppress = suppressAutoRespond
|
|
645
891
|
&& node.id === agent.initialNode
|
|
646
892
|
&& node.autoRespond === undefined;
|
|
647
|
-
const existingTools = node.tools
|
|
648
|
-
|
|
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) {
|
|
649
909
|
return shouldSuppress ? { ...node, autoRespond: false } : node;
|
|
650
910
|
}
|
|
651
911
|
return {
|
|
652
912
|
...node,
|
|
653
913
|
tools: {
|
|
654
|
-
...
|
|
914
|
+
...toolSet,
|
|
655
915
|
handoff: handoffTool,
|
|
656
916
|
},
|
|
657
917
|
...(shouldSuppress ? { autoRespond: false } : {}),
|
|
@@ -666,17 +926,42 @@ ${routeDescriptions}
|
|
|
666
926
|
return agent.flow.nodes.find(node => node.id === nodeId);
|
|
667
927
|
}
|
|
668
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
|
+
}
|
|
669
935
|
const nodeId = flowState?.context.currentNode ?? agent.initialNode;
|
|
670
936
|
const node = this.getFlowNode(agent, nodeId);
|
|
671
937
|
if (!node) {
|
|
672
938
|
return true;
|
|
673
939
|
}
|
|
940
|
+
const rules = agent.detourRules;
|
|
941
|
+
if (rules) {
|
|
942
|
+
const normalized = input.trim().toLowerCase();
|
|
943
|
+
if (rules.allowShortAffirmations !== false &&
|
|
944
|
+
/^(yes|yep|yeah|ok|okay|sure|great|thanks|thank you|please|proceed|go ahead)\b/.test(normalized)) {
|
|
945
|
+
return true;
|
|
946
|
+
}
|
|
947
|
+
if (rules.allowDateTime !== false &&
|
|
948
|
+
(/\b20\d{2}-\d{2}-\d{2}\b/.test(normalized) || /\b\d{1,2}:\d{2}\b/.test(normalized))) {
|
|
949
|
+
return true;
|
|
950
|
+
}
|
|
951
|
+
if (this.matchesDetourRule(normalized, rules.deny)) {
|
|
952
|
+
return false;
|
|
953
|
+
}
|
|
954
|
+
if (this.matchesDetourRule(normalized, rules.allow)) {
|
|
955
|
+
return true;
|
|
956
|
+
}
|
|
957
|
+
}
|
|
674
958
|
const collectedData = flowState?.context.collectedData ?? {};
|
|
959
|
+
const renderedNodePrompt = renderFlowTemplate(node.prompt, collectedData, { missing: 'keep' });
|
|
675
960
|
const routerPrompt = `You are routing a message for a structured conversation flow.
|
|
676
961
|
Decide if the user input is answering the current flow step or is a side question.
|
|
677
962
|
|
|
678
963
|
Current flow step:
|
|
679
|
-
${
|
|
964
|
+
${renderedNodePrompt}
|
|
680
965
|
|
|
681
966
|
Collected data:
|
|
682
967
|
${JSON.stringify(collectedData, null, 2)}
|
|
@@ -686,6 +971,7 @@ Return only one word: "flow" if the input should be handled by the flow step, or
|
|
|
686
971
|
model: (agent.model ?? this.defaultModel),
|
|
687
972
|
system: routerPrompt,
|
|
688
973
|
prompt: input,
|
|
974
|
+
experimental_telemetry: agent.telemetry ?? this.config.telemetry,
|
|
689
975
|
});
|
|
690
976
|
const decision = result.text.trim().toLowerCase();
|
|
691
977
|
if (decision.startsWith('detour')) {
|
|
@@ -696,13 +982,37 @@ Return only one word: "flow" if the input should be handled by the flow step, or
|
|
|
696
982
|
}
|
|
697
983
|
return true;
|
|
698
984
|
}
|
|
699
|
-
|
|
985
|
+
matchesDetourRule(input, patterns) {
|
|
986
|
+
if (!patterns || patterns.length === 0) {
|
|
987
|
+
return false;
|
|
988
|
+
}
|
|
989
|
+
for (const pattern of patterns) {
|
|
990
|
+
try {
|
|
991
|
+
const regex = new RegExp(pattern, 'i');
|
|
992
|
+
if (regex.test(input)) {
|
|
993
|
+
return true;
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
catch {
|
|
997
|
+
if (input.includes(pattern.toLowerCase())) {
|
|
998
|
+
return true;
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
return false;
|
|
1003
|
+
}
|
|
1004
|
+
async *runDetourResponse(agent, context, input, flowState, handoffTool, onHandoff) {
|
|
700
1005
|
const nodeId = flowState?.context.currentNode ?? agent.initialNode;
|
|
701
1006
|
const node = this.getFlowNode(agent, nodeId);
|
|
702
|
-
const nodePrompt = node?.prompt ?? 'Continue the flow.';
|
|
703
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.';
|
|
704
1014
|
const detourPrompt = `You are handling a short detour during a structured flow.
|
|
705
|
-
Answer the user's question clearly and briefly. Then ask the user to continue with the current flow step.
|
|
1015
|
+
Answer the user's question clearly and briefly. ${handoffLine} Then ask the user to continue with the current flow step.
|
|
706
1016
|
|
|
707
1017
|
Current flow step:
|
|
708
1018
|
${nodePrompt}
|
|
@@ -715,14 +1025,79 @@ Do not change the flow requirements. Keep the reply concise.`;
|
|
|
715
1025
|
model: (agent.model ?? this.defaultModel),
|
|
716
1026
|
system: detourPrompt,
|
|
717
1027
|
messages: context.session.messages,
|
|
1028
|
+
tools: handoffTool ? { handoff: handoffTool } : undefined,
|
|
1029
|
+
experimental_telemetry: agent.telemetry ?? this.config.telemetry,
|
|
718
1030
|
});
|
|
1031
|
+
let handoffTriggered = false;
|
|
1032
|
+
const outputProcessors = this.getAgentOutputProcessors(agent);
|
|
1033
|
+
const bufferOutput = this.outputProcessorMode === 'buffer' && outputProcessors.length > 0;
|
|
1034
|
+
let bufferedText = '';
|
|
719
1035
|
for await (const chunk of result.fullStream) {
|
|
720
1036
|
if (chunk.type === 'text-delta') {
|
|
721
|
-
|
|
1037
|
+
if (handoffTriggered) {
|
|
1038
|
+
continue;
|
|
1039
|
+
}
|
|
1040
|
+
if (bufferOutput) {
|
|
1041
|
+
bufferedText += chunk.text;
|
|
1042
|
+
}
|
|
1043
|
+
else {
|
|
1044
|
+
yield* this.emit(context, { type: 'text-delta', text: chunk.text });
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
if (chunk.type === 'tool-call') {
|
|
1048
|
+
const args = 'args' in chunk ? chunk.args : chunk.input;
|
|
1049
|
+
yield* this.emit(context, {
|
|
1050
|
+
type: 'tool-call',
|
|
1051
|
+
toolCallId: chunk.toolCallId,
|
|
1052
|
+
toolName: chunk.toolName,
|
|
1053
|
+
args,
|
|
1054
|
+
});
|
|
1055
|
+
}
|
|
1056
|
+
if (chunk.type === 'tool-result') {
|
|
1057
|
+
const toolResult = 'result' in chunk ? chunk.result : chunk.output;
|
|
1058
|
+
yield* this.emit(context, {
|
|
1059
|
+
type: 'tool-result',
|
|
1060
|
+
toolCallId: chunk.toolCallId,
|
|
1061
|
+
toolName: chunk.toolName,
|
|
1062
|
+
result: toolResult,
|
|
1063
|
+
});
|
|
1064
|
+
if (isHandoffResult(toolResult)) {
|
|
1065
|
+
const targetAgent = toolResult.targetAgent ?? toolResult.targetAgentId;
|
|
1066
|
+
onHandoff(targetAgent, toolResult.reason);
|
|
1067
|
+
handoffTriggered = true;
|
|
1068
|
+
break;
|
|
1069
|
+
}
|
|
722
1070
|
}
|
|
723
1071
|
}
|
|
724
1072
|
const response = await result.response;
|
|
725
|
-
|
|
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
|
+
}
|
|
726
1101
|
const usage = await result.usage;
|
|
727
1102
|
const totalTokens = usage.totalTokens ?? 0;
|
|
728
1103
|
context.totalTokens += totalTokens;
|
|
@@ -730,22 +1105,46 @@ Do not change the flow requirements. Keep the reply concise.`;
|
|
|
730
1105
|
context.session.metadata.totalTokens += totalTokens;
|
|
731
1106
|
context.session.metadata.totalSteps += 1;
|
|
732
1107
|
}
|
|
733
|
-
|
|
1108
|
+
if (!handoffTriggered) {
|
|
1109
|
+
yield* this.emit(context, { type: 'turn-end' });
|
|
1110
|
+
}
|
|
734
1111
|
}
|
|
735
1112
|
async *runFlowAgent(agent, context, input, systemPrompt, handoffTool, onHandoff, toolCalls) {
|
|
736
1113
|
const model = agent.model ?? this.defaultModel;
|
|
737
1114
|
if (!model) {
|
|
738
1115
|
throw new Error(`Agent "${agent.id}" is missing a model`);
|
|
739
1116
|
}
|
|
1117
|
+
const detourRules = agent.detourRules;
|
|
1118
|
+
if (detourRules?.emergency && this.matchesDetourRule(input, detourRules.emergency)) {
|
|
1119
|
+
const emergencyText = detourRules.emergencyMessage
|
|
1120
|
+
?? 'This sounds urgent. Please call local emergency services immediately or go to the nearest emergency room.';
|
|
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 });
|
|
1133
|
+
yield* this.emit(context, { type: 'turn-end' });
|
|
1134
|
+
if (detourRules.emergencyHandoffAgent) {
|
|
1135
|
+
onHandoff(detourRules.emergencyHandoffAgent, 'Emergency pattern matched');
|
|
1136
|
+
}
|
|
1137
|
+
return;
|
|
1138
|
+
}
|
|
740
1139
|
const flowState = this.getFlowState(context.session, agent.id);
|
|
741
1140
|
if (agent.mode === 'hybrid') {
|
|
742
1141
|
const shouldRunFlow = await this.shouldHandleFlowInput(agent, input, flowState);
|
|
743
1142
|
if (!shouldRunFlow) {
|
|
744
|
-
yield* this.runDetourResponse(agent, context, input, flowState);
|
|
1143
|
+
yield* this.runDetourResponse(agent, context, input, flowState, handoffTool, onHandoff);
|
|
745
1144
|
return;
|
|
746
1145
|
}
|
|
747
1146
|
}
|
|
748
|
-
const suppressAutoRespond =
|
|
1147
|
+
const suppressAutoRespond = !flowState?.initialized;
|
|
749
1148
|
const flow = this.buildFlowWithHandoff(agent, handoffTool, suppressAutoRespond);
|
|
750
1149
|
const flowManager = new FlowManager({
|
|
751
1150
|
flow,
|
|
@@ -755,7 +1154,27 @@ Do not change the flow requirements. Keep the reply concise.`;
|
|
|
755
1154
|
contextMessages: flowState?.context.messages ?? [],
|
|
756
1155
|
sessionMessages: context.session.messages,
|
|
757
1156
|
state: flowState,
|
|
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
|
+
},
|
|
758
1176
|
});
|
|
1177
|
+
let persistedStartIndex = context.session.messages.length;
|
|
759
1178
|
if (!flowState?.initialized) {
|
|
760
1179
|
for await (const part of flowManager.initialize()) {
|
|
761
1180
|
switch (part.type) {
|
|
@@ -819,6 +1238,26 @@ Do not change the flow requirements. Keep the reply concise.`;
|
|
|
819
1238
|
});
|
|
820
1239
|
break;
|
|
821
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
|
+
}
|
|
822
1261
|
case 'handoff':
|
|
823
1262
|
onHandoff(part.targetAgent, part.reason);
|
|
824
1263
|
break;
|
|
@@ -844,6 +1283,18 @@ Do not change the flow requirements. Keep the reply concise.`;
|
|
|
844
1283
|
break;
|
|
845
1284
|
}
|
|
846
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;
|
|
847
1298
|
}
|
|
848
1299
|
for await (const part of flowManager.process(input, { appendUserToSession: false })) {
|
|
849
1300
|
switch (part.type) {
|
|
@@ -907,6 +1358,26 @@ Do not change the flow requirements. Keep the reply concise.`;
|
|
|
907
1358
|
});
|
|
908
1359
|
break;
|
|
909
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
|
+
}
|
|
910
1381
|
case 'handoff':
|
|
911
1382
|
onHandoff(part.targetAgent, part.reason);
|
|
912
1383
|
break;
|
|
@@ -932,6 +1403,17 @@ Do not change the flow requirements. Keep the reply concise.`;
|
|
|
932
1403
|
break;
|
|
933
1404
|
}
|
|
934
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
|
+
}
|
|
935
1417
|
if (flowManager.hasEnded) {
|
|
936
1418
|
this.clearFlowState(context.session, agent.id);
|
|
937
1419
|
}
|
|
@@ -951,10 +1433,112 @@ Do not change the flow requirements. Keep the reply concise.`;
|
|
|
951
1433
|
getAllAgents() {
|
|
952
1434
|
return Array.from(this.agents.values());
|
|
953
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
|
+
}
|
|
954
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
|
+
}
|
|
955
1518
|
await this.hookRunner.onStreamPart(context, part);
|
|
956
1519
|
yield part;
|
|
957
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
|
+
}
|
|
958
1542
|
}
|
|
959
1543
|
export function createRuntime(config) {
|
|
960
1544
|
return new Runtime(config);
|