@ariaflowagents/core 0.5.1 → 0.5.3

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