@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.
Files changed (68) hide show
  1. package/README.md +51 -1
  2. package/dist/agents/TriageAgent.d.ts.map +1 -1
  3. package/dist/agents/TriageAgent.js +3 -2
  4. package/dist/agents/TriageAgent.js.map +1 -1
  5. package/dist/callbacks/httpCallback.js +5 -5
  6. package/dist/callbacks/httpCallback.js.map +1 -1
  7. package/dist/flows/AgentFlowManager.d.ts +3 -1
  8. package/dist/flows/AgentFlowManager.d.ts.map +1 -1
  9. package/dist/flows/AgentFlowManager.js +70 -4
  10. package/dist/flows/AgentFlowManager.js.map +1 -1
  11. package/dist/flows/FlowManager.d.ts +24 -3
  12. package/dist/flows/FlowManager.d.ts.map +1 -1
  13. package/dist/flows/FlowManager.js +195 -14
  14. package/dist/flows/FlowManager.js.map +1 -1
  15. package/dist/flows/index.d.ts +1 -1
  16. package/dist/flows/index.d.ts.map +1 -1
  17. package/dist/flows/index.js +1 -1
  18. package/dist/flows/index.js.map +1 -1
  19. package/dist/flows/template.d.ts +13 -0
  20. package/dist/flows/template.d.ts.map +1 -0
  21. package/dist/flows/template.js +64 -0
  22. package/dist/flows/template.js.map +1 -0
  23. package/dist/flows/transitions.d.ts +12 -0
  24. package/dist/flows/transitions.d.ts.map +1 -1
  25. package/dist/flows/transitions.js +23 -0
  26. package/dist/flows/transitions.js.map +1 -1
  27. package/dist/index.d.ts +1 -1
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +1 -1
  30. package/dist/index.js.map +1 -1
  31. package/dist/processors/ProcessorRunner.d.ts +39 -0
  32. package/dist/processors/ProcessorRunner.d.ts.map +1 -0
  33. package/dist/processors/ProcessorRunner.js +47 -0
  34. package/dist/processors/ProcessorRunner.js.map +1 -0
  35. package/dist/runtime/InjectionQueue.d.ts +10 -0
  36. package/dist/runtime/InjectionQueue.d.ts.map +1 -1
  37. package/dist/runtime/InjectionQueue.js +14 -0
  38. package/dist/runtime/InjectionQueue.js.map +1 -1
  39. package/dist/runtime/Runtime.d.ts +14 -0
  40. package/dist/runtime/Runtime.d.ts.map +1 -1
  41. package/dist/runtime/Runtime.js +649 -65
  42. package/dist/runtime/Runtime.js.map +1 -1
  43. package/dist/tools/Tool.d.ts.map +1 -1
  44. package/dist/tools/Tool.js +11 -2
  45. package/dist/tools/Tool.js.map +1 -1
  46. package/dist/tools/handoff.d.ts +0 -1
  47. package/dist/tools/handoff.d.ts.map +1 -1
  48. package/dist/tools/handoff.js +6 -4
  49. package/dist/tools/handoff.js.map +1 -1
  50. package/dist/tools/http.d.ts +3 -3
  51. package/dist/tools/http.d.ts.map +1 -1
  52. package/dist/tools/http.js +4 -3
  53. package/dist/tools/http.js.map +1 -1
  54. package/dist/tools/http.types.d.ts.map +1 -1
  55. package/dist/types/index.d.ts +137 -2
  56. package/dist/types/index.d.ts.map +1 -1
  57. package/dist/types/index.js.map +1 -1
  58. package/dist/utils/chrono.d.ts +2 -2
  59. package/dist/utils/chrono.d.ts.map +1 -1
  60. package/dist/utils/chrono.js +4 -1
  61. package/dist/utils/chrono.js.map +1 -1
  62. package/guides/FLOWS.md +79 -0
  63. package/guides/GETTING_STARTED.md +58 -0
  64. package/guides/GUARDRAILS.md +85 -0
  65. package/guides/README.md +16 -0
  66. package/guides/RUNTIME.md +88 -0
  67. package/guides/TOOLS.md +66 -0
  68. 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,
@@ -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
- 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);
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
- if (context.handoffStack.includes(context.agentId)) {
180
- yield* this.emit(context, {
181
- type: 'error',
182
- error: `Circular handoff detected: ${context.handoffStack.join(' -> ')} -> ${context.agentId}`,
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 = createHandoffTool(handoffCandidates, context.agentId);
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, input, system, handoffTool, (target, reason) => {
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
- const result = await generateObject({
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
- 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
+ }
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
- 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
+ }
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
- 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
+ }
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
- if (finalResult) {
466
- 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 });
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
- 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));
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
- 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) {
649
909
  return shouldSuppress ? { ...node, autoRespond: false } : node;
650
910
  }
651
911
  return {
652
912
  ...node,
653
913
  tools: {
654
- ...existingTools,
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
- ${node.prompt}
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
- async *runDetourResponse(agent, context, input, flowState) {
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
- yield* this.emit(context, { type: 'text-delta', text: chunk.text });
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
- 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
+ }
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
- yield* this.emit(context, { type: 'turn-end' });
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 = context.session.messages.length > 1 && !flowState?.initialized;
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);