@ariaflowagents/core 0.5.1 → 0.5.2

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