@kuralle-agents/core 0.3.20 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -48,7 +48,7 @@ const runtime = createRuntime({ agents: [agent], defaultAgentId: 'support' });
48
48
 
49
49
  const handle = runtime.run({ input: 'Hello', sessionId: 'demo' });
50
50
  for await (const part of handle.events) { // events is a property, not a method
51
- if (part.type === 'text-delta') process.stdout.write(part.text);
51
+ if (part.type === 'text-delta') process.stdout.write(part.delta);
52
52
  if (part.type === 'done') console.log('\nSession:', part.sessionId);
53
53
  }
54
54
  await handle; // resolves to TurnResult once the stream is consumed
@@ -3,6 +3,8 @@ import type { Session, SourceRef, ToolCallRecord } from '../types/index.js';
3
3
  export interface ValidationCapability {
4
4
  readonly name: string;
5
5
  readonly order?: 'parallel' | 'serial';
6
+ /** Absent ⇒ `turn` (buffered, safe). Streaming is an explicit opt-in by the gate author. */
7
+ readonly streamGranularity?: 'sentence' | 'turn';
6
8
  validate(input: ValidateInput): Promise<ValidateDecision>;
7
9
  }
8
10
  export interface ValidateInput {
@@ -25,7 +25,7 @@ export class EvalRunner {
25
25
  const handle = runtime.run({ input: turn.input, sessionId });
26
26
  for await (const part of handle.events) {
27
27
  if (part.type === 'text-delta') {
28
- response += part.text;
28
+ response += part.delta;
29
29
  }
30
30
  if (part.type === 'tool-call') {
31
31
  toolsCalled.push(part.toolName);
@@ -76,7 +76,10 @@ export async function runCollectDigression(options) {
76
76
  };
77
77
  const turn = await driver.runAgentTurn(resolveReplyNode(replyNode, run.state, { freeConversation: true }), ctx);
78
78
  if (turn.text.trim()) {
79
- ctx.emit({ type: 'text-delta', text: turn.text });
79
+ const id = crypto.randomUUID();
80
+ ctx.emit({ type: 'text-start', id });
81
+ ctx.emit({ type: 'text-delta', id, delta: turn.text });
82
+ ctx.emit({ type: 'text-end', id });
80
83
  ctx.emit({ type: 'turn-end' });
81
84
  appendAssistantMessage(run, turn.text);
82
85
  }
@@ -111,7 +111,10 @@ export function emitCollectAsk(node, run, ctx) {
111
111
  if (!text.trim()) {
112
112
  return;
113
113
  }
114
- ctx.emit({ type: 'text-delta', text });
114
+ const id = crypto.randomUUID();
115
+ ctx.emit({ type: 'text-start', id });
116
+ ctx.emit({ type: 'text-delta', id, delta: text });
117
+ ctx.emit({ type: 'text-end', id });
115
118
  ctx.emit({ type: 'turn-end' });
116
119
  appendAssistantMessage(run, text);
117
120
  }
@@ -6,7 +6,10 @@ export function findEscalateNode(registry) {
6
6
  export function appendSafeAssistantMessage(run, ctx, text = SAFE_DEGRADED_MESSAGE) {
7
7
  const message = { role: 'assistant', content: text };
8
8
  run.messages = [...run.messages, message];
9
- ctx.emit({ type: 'text-delta', text });
9
+ const id = crypto.randomUUID();
10
+ ctx.emit({ type: 'text-start', id });
11
+ ctx.emit({ type: 'text-delta', id, delta: text });
12
+ ctx.emit({ type: 'text-end', id });
10
13
  }
11
14
  export async function degradeFlowError(flow, registry, run, driver, ctx, dispatchNode) {
12
15
  appendSafeAssistantMessage(run, ctx);
@@ -33,7 +33,7 @@ export class DefaultConversationEventLog {
33
33
  if (part.type === 'text-delta') {
34
34
  const prevRaw = context.session.workingMemory[ASSISTANT_TEXT_KEY];
35
35
  const prev = typeof prevRaw === 'string' ? prevRaw : '';
36
- context.session.workingMemory[ASSISTANT_TEXT_KEY] = prev + part.text;
36
+ context.session.workingMemory[ASSISTANT_TEXT_KEY] = prev + part.delta;
37
37
  return;
38
38
  }
39
39
  const base = {
@@ -74,7 +74,12 @@ export class Runtime {
74
74
  tools: effectTools,
75
75
  enforcer: policies.enforcer,
76
76
  agentId: opened.agent.id,
77
- onInterim: (message) => emit({ type: 'text-delta', text: message }),
77
+ onInterim: (message) => {
78
+ const id = crypto.randomUUID();
79
+ emit({ type: 'text-start', id });
80
+ emit({ type: 'text-delta', id, delta: message });
81
+ emit({ type: 'text-end', id });
82
+ },
78
83
  });
79
84
  const steps = await loadRecordedSteps(opened.runStore, opened.runState.runId);
80
85
  const freshRunState = (await opened.runStore.getRunState(opened.runState.runId)) ?? opened.runState;
@@ -173,7 +178,10 @@ export class Runtime {
173
178
  if (isDegradableRuntimeError(error)) {
174
179
  const message = error instanceof Error ? error.message : String(error);
175
180
  emit({ type: 'error', error: message });
176
- emit({ type: 'text-delta', text: SAFE_DEGRADED_MESSAGE });
181
+ const degradedId = crypto.randomUUID();
182
+ emit({ type: 'text-start', id: degradedId });
183
+ emit({ type: 'text-delta', id: degradedId, delta: SAFE_DEGRADED_MESSAGE });
184
+ emit({ type: 'text-end', id: degradedId });
177
185
  runCtx.runState.messages = [
178
186
  ...runCtx.runState.messages,
179
187
  { role: 'assistant', content: SAFE_DEGRADED_MESSAGE },
@@ -6,6 +6,8 @@ import { consumePendingUserInput } from './inputBuffer.js';
6
6
  import { runSilentExtraction } from './extractionTurn.js';
7
7
  import { applyPreTurnPolicies, applyPostTurnPolicies } from '../policies/agentTurn.js';
8
8
  import { resolveMaxSteps } from '../policies/limits.js';
9
+ import { speakGated } from './streaming/speakGated.js';
10
+ import { resolveStreamMode } from './streaming/mode.js';
9
11
  import { appendGatherBlocks, resolveNodeGatherScope, runGatherPhase } from '../grounding/index.js';
10
12
  import { isFlowTransitionControlTool } from '../../flow/flowControlTools.js';
11
13
  import { resolveStructuredDecide } from '../../flow/choiceMatch.js';
@@ -24,7 +26,10 @@ export class TextDriver {
24
26
  const preTurn = await applyPreTurnPolicies(ctx);
25
27
  if (!preTurn.proceed) {
26
28
  const blocked = preTurn.blockedMessage ?? 'Input blocked by guardrails';
27
- ctx.emit({ type: 'text-delta', text: blocked });
29
+ const id = crypto.randomUUID();
30
+ ctx.emit({ type: 'text-start', id });
31
+ ctx.emit({ type: 'text-delta', id, delta: blocked });
32
+ ctx.emit({ type: 'text-end', id });
28
33
  ctx.emit({ type: 'turn-end' });
29
34
  return { text: blocked, toolResults: [] };
30
35
  }
@@ -39,73 +44,89 @@ export class TextDriver {
39
44
  const aiTools = this.resolveTools(node, ctx);
40
45
  const maxSteps = resolveMaxSteps(ctx.limits, this.maxSteps);
41
46
  const toolCallsMade = [];
42
- let draftText = '';
43
- for (let step = 0; step < maxSteps; step += 1) {
44
- const result = streamText({
45
- model,
46
- system,
47
- messages,
48
- tools: aiTools,
49
- abortSignal: ctx.abortSignal,
50
- });
51
- for await (const part of result.fullStream) {
52
- if (part.type === 'text-delta') {
53
- draftText += part.text;
47
+ const mode = resolveStreamMode(ctx, node);
48
+ const turnId = crypto.randomUUID();
49
+ const source = {
50
+ async *[Symbol.asyncIterator]() {
51
+ for (let step = 0; step < maxSteps; step += 1) {
52
+ const result = streamText({
53
+ model,
54
+ system,
55
+ messages,
56
+ tools: aiTools,
57
+ abortSignal: ctx.abortSignal,
58
+ });
59
+ for await (const part of result.fullStream) {
60
+ if (part.type === 'text-delta') {
61
+ yield { delta: part.text };
62
+ }
63
+ if (part.type === 'error') {
64
+ const err = part.error;
65
+ const message = err instanceof Error ? err.message : String(err);
66
+ ctx.emit({ type: 'error', error: message });
67
+ throw err instanceof Error ? err : new Error(message);
68
+ }
69
+ }
70
+ const finishReason = await result.finishReason;
71
+ const response = await result.response;
72
+ messages.push(...response.messages);
73
+ if (finishReason !== 'tool-calls') {
74
+ break;
75
+ }
76
+ const toolCalls = await result.toolCalls;
77
+ for (const call of toolCalls) {
78
+ ctx.emit({
79
+ type: 'tool-call',
80
+ toolName: call.toolName,
81
+ args: call.input,
82
+ toolCallId: call.toolCallId,
83
+ });
84
+ const { result: toolResult, control, failed } = await executeModelToolCall(ctx, { toolName: call.toolName, input: call.input, toolCallId: call.toolCallId }, node.localTools);
85
+ out.toolResults.push({
86
+ name: call.toolName,
87
+ args: call.input,
88
+ result: toolResult,
89
+ toolCallId: call.toolCallId,
90
+ });
91
+ toolCallsMade.push({
92
+ toolCallId: call.toolCallId,
93
+ toolName: call.toolName,
94
+ args: call.input,
95
+ result: toolResult,
96
+ success: !failed,
97
+ timestamp: Date.now(),
98
+ });
99
+ out.control ??= control;
100
+ ctx.emit({
101
+ type: 'tool-result',
102
+ toolName: call.toolName,
103
+ result: toolResult,
104
+ toolCallId: call.toolCallId,
105
+ });
106
+ messages.push(toolResultMessage({ toolName: call.toolName, input: call.input, toolCallId: call.toolCallId }, toolResult));
107
+ }
54
108
  }
55
- if (part.type === 'error') {
56
- const err = part.error;
57
- const message = err instanceof Error ? err.message : String(err);
58
- ctx.emit({ type: 'error', error: message });
59
- throw err instanceof Error ? err : new Error(message);
60
- }
61
- }
62
- const finishReason = await result.finishReason;
63
- const response = await result.response;
64
- messages.push(...response.messages);
65
- if (finishReason !== 'tool-calls') {
66
- break;
67
- }
68
- const toolCalls = await result.toolCalls;
69
- for (const call of toolCalls) {
70
- ctx.emit({
71
- type: 'tool-call',
72
- toolName: call.toolName,
73
- args: call.input,
74
- toolCallId: call.toolCallId,
75
- });
76
- const { result: toolResult, control, failed } = await executeModelToolCall(ctx, { toolName: call.toolName, input: call.input, toolCallId: call.toolCallId }, node.localTools);
77
- out.toolResults.push({
78
- name: call.toolName,
79
- args: call.input,
80
- result: toolResult,
81
- toolCallId: call.toolCallId,
82
- });
83
- toolCallsMade.push({
84
- toolCallId: call.toolCallId,
85
- toolName: call.toolName,
86
- args: call.input,
87
- result: toolResult,
88
- success: !failed,
89
- timestamp: Date.now(),
90
- });
91
- out.control ??= control;
92
- ctx.emit({
93
- type: 'tool-result',
94
- toolName: call.toolName,
95
- result: toolResult,
96
- toolCallId: call.toolCallId,
97
- });
98
- messages.push(toolResultMessage({ toolName: call.toolName, input: call.input, toolCallId: call.toolCallId }, toolResult));
99
- }
100
- }
101
- const postTurn = await applyPostTurnPolicies(ctx, draftText, toolCallsMade, gather.citations ?? []);
102
- out.confidence = postTurn.confidence;
103
- out.control = postTurn.control;
104
- const emitText = postTurn.control ? (postTurn.blockedMessage ?? postTurn.text) : postTurn.text;
105
- out.text = emitText;
106
- if (emitText) {
107
- ctx.emit({ type: 'text-delta', text: emitText });
108
- }
109
+ },
110
+ };
111
+ const spoken = await speakGated({
112
+ ctx,
113
+ mode,
114
+ turnId,
115
+ source,
116
+ runGate: async (text, _final) => {
117
+ const r = await applyPostTurnPolicies(ctx, text, toolCallsMade, gather.citations ?? []);
118
+ return {
119
+ blocked: !r.proceed,
120
+ text: r.proceed ? r.text : (r.blockedMessage ?? r.text),
121
+ reason: r.control?.reason,
122
+ control: r.control,
123
+ confidence: r.confidence,
124
+ };
125
+ },
126
+ });
127
+ out.text = spoken.text;
128
+ out.control = spoken.control;
129
+ out.confidence = spoken.confidence;
109
130
  ctx.emit({ type: 'turn-end' });
110
131
  return out;
111
132
  }
@@ -4,8 +4,35 @@ import { buildNodePrompt, resolveInstructions, composeSystem } from '../../flow/
4
4
  import { executeModelToolCall } from './executeModelTool.js';
5
5
  import { applyPreTurnPolicies, applyPostTurnPolicies } from '../policies/agentTurn.js';
6
6
  import { resolveMaxSteps } from '../policies/limits.js';
7
+ import { speakGated } from './streaming/speakGated.js';
8
+ import { resolveStreamMode } from './streaming/mode.js';
9
+ import { createDeferredTokenSource, } from './streaming/deferredTokenSource.js';
7
10
  import { appendGatherBlocks, resolveNodeGatherScope, runGatherPhase } from '../grounding/index.js';
8
11
  import { resolveVoiceGeminiTools } from './voiceTools.js';
12
+ function emitNativeRealtimePostHocGate(ctx, client, gate) {
13
+ if (gate.escalated) {
14
+ ctx.emit({
15
+ type: 'safety-blocked',
16
+ moderator: gate.moderator,
17
+ rationale: gate.rationale,
18
+ userFacingMessage: gate.safeText,
19
+ });
20
+ }
21
+ else {
22
+ ctx.emit({
23
+ type: 'pipeline-validation-block',
24
+ rationale: gate.rationale,
25
+ userFacingMessage: gate.safeText,
26
+ });
27
+ ctx.emit({
28
+ type: 'safety-blocked',
29
+ moderator: gate.moderator,
30
+ rationale: gate.rationale,
31
+ userFacingMessage: gate.safeText,
32
+ });
33
+ }
34
+ client.requestResponse?.(gate.safeText);
35
+ }
9
36
  export class VoiceDriver {
10
37
  client;
11
38
  toolDefs;
@@ -25,7 +52,10 @@ export class VoiceDriver {
25
52
  const preTurn = await applyPreTurnPolicies(ctx);
26
53
  if (!preTurn.proceed) {
27
54
  const blocked = preTurn.blockedMessage ?? 'Input blocked by guardrails';
28
- ctx.emit({ type: 'text-delta', text: blocked });
55
+ const id = crypto.randomUUID();
56
+ ctx.emit({ type: 'text-start', id });
57
+ ctx.emit({ type: 'text-delta', id, delta: blocked });
58
+ ctx.emit({ type: 'text-end', id });
29
59
  ctx.emit({ type: 'turn-end' });
30
60
  return { text: blocked, toolResults: [] };
31
61
  }
@@ -38,17 +68,56 @@ export class VoiceDriver {
38
68
  const geminiTools = resolveVoiceGeminiTools(node, { ...this.toolDefs, ...(ctx.globalTools ?? {}) }, { siloFlowControl: ctx.outOfBandControl && !node.freeConversation });
39
69
  const toolCallsMade = [];
40
70
  const maxSteps = resolveMaxSteps(ctx.limits, this.maxSteps);
71
+ const mode = resolveStreamMode(ctx, node);
72
+ const turnId = crypto.randomUUID();
73
+ const transcript = createDeferredTokenSource();
41
74
  let draftText = '';
42
75
  this.heardCharCount = 0;
76
+ let postHocGate = null;
77
+ const speakPromise = speakGated({
78
+ ctx,
79
+ mode,
80
+ turnId,
81
+ source: transcript.source,
82
+ runGate: async (text, _final) => {
83
+ const r = await applyPostTurnPolicies(ctx, text, toolCallsMade, gather.citations ?? []);
84
+ if (!r.proceed) {
85
+ const safeText = r.blockedMessage ?? r.text;
86
+ postHocGate = {
87
+ safeText,
88
+ rationale: r.control?.reason ?? 'blocked',
89
+ moderator: 'post-turn-gate',
90
+ escalated: r.control?.type === 'escalate',
91
+ };
92
+ }
93
+ return {
94
+ blocked: !r.proceed,
95
+ text: r.proceed ? r.text : (r.blockedMessage ?? r.text),
96
+ reason: r.control?.reason,
97
+ control: r.control,
98
+ confidence: r.confidence,
99
+ };
100
+ },
101
+ });
43
102
  await this.reconfigure({ systemInstruction: system, tools: geminiTools });
44
103
  for (let step = 0; step < maxSteps; step += 1) {
45
- const { outcome, assistantText } = await this.collectProviderTurn(ctx, out, step === 0, toolCallsMade, node.localTools);
104
+ const { outcome, assistantText } = await this.collectProviderTurn(ctx, out, step === 0, toolCallsMade, node.localTools, transcript);
46
105
  draftText += assistantText;
47
106
  if (outcome === 'interrupted') {
107
+ transcript.close('interrupted');
108
+ try {
109
+ await speakPromise;
110
+ }
111
+ catch {
112
+ /* speakGated cancels in-flight stream on interrupt */
113
+ }
48
114
  out.interrupted = true;
49
115
  out.truncateAt = this.heardCharCount;
50
116
  out.text = truncateToHeard(draftText, this.heardCharCount);
51
- ctx.emit({ type: 'text-delta', text: out.text });
117
+ const id = crypto.randomUUID();
118
+ ctx.emit({ type: 'text-start', id });
119
+ ctx.emit({ type: 'text-delta', id, delta: out.text });
120
+ ctx.emit({ type: 'text-end', id });
52
121
  ctx.emit({ type: 'turn-end' });
53
122
  return out;
54
123
  }
@@ -56,13 +125,15 @@ export class VoiceDriver {
56
125
  break;
57
126
  }
58
127
  }
59
- const postTurn = await applyPostTurnPolicies(ctx, draftText, toolCallsMade, gather.citations ?? []);
60
- out.confidence = postTurn.confidence;
61
- out.control = postTurn.control;
62
- const emitText = postTurn.control ? (postTurn.blockedMessage ?? postTurn.text) : postTurn.text;
63
- out.text = emitText;
64
- if (emitText) {
65
- ctx.emit({ type: 'text-delta', text: emitText });
128
+ transcript.close('complete');
129
+ const spoken = await speakPromise;
130
+ out.text = spoken.text;
131
+ out.control = spoken.control;
132
+ out.confidence = spoken.confidence;
133
+ if (postHocGate) {
134
+ // Advisory post-hoc gate: provider audio may already have played; correction does not un-speak it.
135
+ emitNativeRealtimePostHocGate(ctx, this.client, postHocGate);
136
+ out.gateScope = 'advisory';
66
137
  }
67
138
  ctx.emit({ type: 'turn-end' });
68
139
  return out;
@@ -94,7 +165,7 @@ export class VoiceDriver {
94
165
  getHeardCharCount() {
95
166
  return this.heardCharCount;
96
167
  }
97
- async collectProviderTurn(ctx, out, triggerResponse, toolCallsMade, localTools) {
168
+ async collectProviderTurn(ctx, out, triggerResponse, toolCallsMade, localTools, transcript) {
98
169
  return new Promise((resolve, reject) => {
99
170
  let assistantText = '';
100
171
  let settled = false;
@@ -111,12 +182,14 @@ export class VoiceDriver {
111
182
  return;
112
183
  settled = true;
113
184
  cleanup();
185
+ transcript.close('error');
114
186
  reject(error);
115
187
  };
116
188
  const onTranscript = (text, role) => {
117
189
  if (role === 'assistant') {
118
190
  assistantText += text;
119
191
  this.heardCharCount += text.length;
192
+ transcript.push(text);
120
193
  }
121
194
  if (role === 'user' && sawInterrupt) {
122
195
  this.pendingBargeInInput = text;
@@ -0,0 +1,12 @@
1
+ export declare function matchEndOfSentence(text: string): number;
2
+ export declare class SentenceAggregator {
3
+ private buffer;
4
+ private needsLookahead;
5
+ private pendingPeriodConfirm;
6
+ push(tokenText: string): string[];
7
+ flush(): string | null;
8
+ private confirmPendingPeriod;
9
+ private checkSentenceWithLookahead;
10
+ private drainConfirmedAtBufferEnd;
11
+ private emitFirstSentenceIfFound;
12
+ }
@@ -0,0 +1,196 @@
1
+ const ABBREVIATIONS = [
2
+ 'mr.',
3
+ 'mrs.',
4
+ 'ms.',
5
+ 'dr.',
6
+ 'prof.',
7
+ 'st.',
8
+ 'e.g.',
9
+ 'i.e.',
10
+ 'etc.',
11
+ 'vs.',
12
+ 'no.',
13
+ ];
14
+ const TERMINAL_PUNCT_RE = /(?:\.{3}|[!?]+|\.)/g;
15
+ const MIN_WORDS_TO_CONFIRM_PERIOD_AT_TOKEN_END = 3;
16
+ function hasWordCharBefore(text, index) {
17
+ return index > 0 && /\w/.test(text[index - 1]);
18
+ }
19
+ function isDecimalPeriod(text, periodIndex) {
20
+ if (text[periodIndex] !== '.')
21
+ return false;
22
+ const before = periodIndex > 0 ? text[periodIndex - 1] : '';
23
+ const after = periodIndex < text.length - 1 ? text[periodIndex + 1] : '';
24
+ return /\d/.test(before) && /\d/.test(after);
25
+ }
26
+ function isAbbreviationPeriod(text, periodIndex) {
27
+ if (text[periodIndex] !== '.')
28
+ return false;
29
+ const prefix = text.slice(0, periodIndex + 1).toLowerCase();
30
+ for (const abbr of ABBREVIATIONS) {
31
+ if (prefix.endsWith(abbr)) {
32
+ const start = prefix.length - abbr.length;
33
+ if (!hasWordCharBefore(prefix, start))
34
+ return true;
35
+ }
36
+ for (let len = 1; len < abbr.length; len++) {
37
+ const partial = abbr.slice(0, len);
38
+ if (!partial.endsWith('.'))
39
+ continue;
40
+ if (!prefix.endsWith(partial))
41
+ continue;
42
+ const start = prefix.length - partial.length;
43
+ if (!hasWordCharBefore(prefix, start))
44
+ return true;
45
+ }
46
+ }
47
+ return false;
48
+ }
49
+ function endsWithSentencePunctuation(text) {
50
+ if (/[!?]+$/.test(text))
51
+ return true;
52
+ if (/\.{3,}$/.test(text))
53
+ return true;
54
+ if (/\.$/.test(text) && !/\.{2,}$/.test(text))
55
+ return true;
56
+ return false;
57
+ }
58
+ function endsWithIncompleteEllipsis(text) {
59
+ return /\.{2}$/.test(text) && !/\.{3,}$/.test(text);
60
+ }
61
+ function remainderStartsNewSentence(text, endIndex) {
62
+ const rest = text.slice(endIndex);
63
+ return /^\s*$/.test(rest) || /^\s+[A-Z]/.test(rest);
64
+ }
65
+ function wordCount(text) {
66
+ const trimmed = text.trim();
67
+ if (!trimmed)
68
+ return 0;
69
+ return trimmed.split(/\s+/).length;
70
+ }
71
+ function isValidSentenceEnd(text, endIndex) {
72
+ const punct = text.slice(0, endIndex).match(/(?:\.{3}|[!?]+|\.)$/)?.[0];
73
+ if (!punct)
74
+ return false;
75
+ const periodIndex = endIndex - punct.length;
76
+ if (punct === '.' || (punct.startsWith('.') && punct.length === 1)) {
77
+ if (endIndex < text.length && text[endIndex] === '.')
78
+ return false;
79
+ if (isDecimalPeriod(text, periodIndex))
80
+ return false;
81
+ if (isAbbreviationPeriod(text, periodIndex))
82
+ return false;
83
+ }
84
+ if (endIndex === text.length && endsWithIncompleteEllipsis(text.slice(0, endIndex))) {
85
+ return false;
86
+ }
87
+ return true;
88
+ }
89
+ export function matchEndOfSentence(text) {
90
+ if (!text)
91
+ return 0;
92
+ TERMINAL_PUNCT_RE.lastIndex = 0;
93
+ let match;
94
+ while ((match = TERMINAL_PUNCT_RE.exec(text)) !== null) {
95
+ const end = match.index + match[0].length;
96
+ if (!isValidSentenceEnd(text, end))
97
+ continue;
98
+ if (remainderStartsNewSentence(text, end))
99
+ return end;
100
+ }
101
+ if (endsWithIncompleteEllipsis(text))
102
+ return 0;
103
+ const trailing = text.match(/(?:\.{3}|[!?]+|\.)$/);
104
+ if (trailing && isValidSentenceEnd(text, text.length))
105
+ return text.length;
106
+ return 0;
107
+ }
108
+ export class SentenceAggregator {
109
+ buffer = '';
110
+ needsLookahead = false;
111
+ pendingPeriodConfirm = false;
112
+ push(tokenText) {
113
+ if (tokenText === '')
114
+ return [];
115
+ if (/^\s+$/.test(tokenText)) {
116
+ this.buffer += tokenText;
117
+ return [];
118
+ }
119
+ const sentences = [];
120
+ if (this.pendingPeriodConfirm) {
121
+ const confirmed = this.confirmPendingPeriod(tokenText[0]);
122
+ if (confirmed !== null)
123
+ sentences.push(confirmed);
124
+ }
125
+ for (const char of tokenText) {
126
+ this.buffer += char;
127
+ const completed = this.checkSentenceWithLookahead(char);
128
+ if (completed !== null)
129
+ sentences.push(completed);
130
+ }
131
+ const drained = this.drainConfirmedAtBufferEnd();
132
+ if (drained !== null)
133
+ sentences.push(drained);
134
+ return sentences;
135
+ }
136
+ flush() {
137
+ if (this.buffer.length === 0)
138
+ return null;
139
+ const tail = this.buffer;
140
+ this.buffer = '';
141
+ this.needsLookahead = false;
142
+ this.pendingPeriodConfirm = false;
143
+ return tail;
144
+ }
145
+ confirmPendingPeriod(nextChar) {
146
+ this.pendingPeriodConfirm = false;
147
+ if (nextChar.trim() === '') {
148
+ this.needsLookahead = false;
149
+ return this.emitFirstSentenceIfFound();
150
+ }
151
+ this.needsLookahead = false;
152
+ return null;
153
+ }
154
+ checkSentenceWithLookahead(char) {
155
+ if (this.needsLookahead) {
156
+ if (char.trim() !== '') {
157
+ this.needsLookahead = false;
158
+ this.pendingPeriodConfirm = false;
159
+ return this.emitFirstSentenceIfFound();
160
+ }
161
+ return null;
162
+ }
163
+ if (this.buffer.length > 0 && endsWithSentencePunctuation(this.buffer)) {
164
+ this.needsLookahead = true;
165
+ }
166
+ return null;
167
+ }
168
+ drainConfirmedAtBufferEnd() {
169
+ if (!this.needsLookahead)
170
+ return null;
171
+ if (this.buffer.endsWith('.') && !/\.{2,}$/.test(this.buffer)) {
172
+ if (wordCount(this.buffer) < MIN_WORDS_TO_CONFIRM_PERIOD_AT_TOKEN_END) {
173
+ this.pendingPeriodConfirm = true;
174
+ return null;
175
+ }
176
+ }
177
+ const end = matchEndOfSentence(this.buffer);
178
+ if (end > 0 && end === this.buffer.length) {
179
+ this.needsLookahead = false;
180
+ this.pendingPeriodConfirm = false;
181
+ const sentence = this.buffer;
182
+ this.buffer = '';
183
+ return sentence.length > 0 ? sentence : null;
184
+ }
185
+ return null;
186
+ }
187
+ emitFirstSentenceIfFound() {
188
+ const end = matchEndOfSentence(this.buffer);
189
+ if (end > 0) {
190
+ const sentence = this.buffer.slice(0, end);
191
+ this.buffer = this.buffer.slice(end);
192
+ return sentence.length > 0 ? sentence : null;
193
+ }
194
+ return null;
195
+ }
196
+ }
@@ -0,0 +1,8 @@
1
+ import type { TokenSource } from './speakGated.js';
2
+ export type DeferredCloseReason = 'complete' | 'interrupted' | 'error';
3
+ export interface DeferredTokenSource {
4
+ source: TokenSource;
5
+ push(delta: string): void;
6
+ close(reason?: DeferredCloseReason): void;
7
+ }
8
+ export declare function createDeferredTokenSource(): DeferredTokenSource;
@@ -0,0 +1,45 @@
1
+ export function createDeferredTokenSource() {
2
+ const queue = [];
3
+ let closed = false;
4
+ let closeReason;
5
+ const waiters = [];
6
+ const notify = () => {
7
+ for (const wake of waiters)
8
+ wake();
9
+ waiters.length = 0;
10
+ };
11
+ const source = {
12
+ async *[Symbol.asyncIterator]() {
13
+ while (true) {
14
+ while (queue.length > 0) {
15
+ yield queue.shift();
16
+ }
17
+ if (closed) {
18
+ if (closeReason === 'interrupted') {
19
+ throw new Error('interrupted');
20
+ }
21
+ return;
22
+ }
23
+ await new Promise((resolve) => {
24
+ waiters.push(resolve);
25
+ });
26
+ }
27
+ },
28
+ };
29
+ return {
30
+ source,
31
+ push(delta) {
32
+ if (closed || delta.length === 0)
33
+ return;
34
+ queue.push({ delta });
35
+ notify();
36
+ },
37
+ close(reason = 'complete') {
38
+ if (closed)
39
+ return;
40
+ closed = true;
41
+ closeReason = reason;
42
+ notify();
43
+ },
44
+ };
45
+ }
@@ -0,0 +1,4 @@
1
+ import type { RunContext } from '../../../types/run-context.js';
2
+ import type { ResolvedNode } from '../../../types/channel.js';
3
+ export type StreamMode = 'token' | 'sentence' | 'turn';
4
+ export declare function resolveStreamMode(ctx: RunContext, node: ResolvedNode): StreamMode;
@@ -0,0 +1,16 @@
1
+ function nodeHasWholeAnswerGroundingGate(_ctx, node) {
2
+ return node.node.kind === 'reply' && node.node.confidenceGate != null;
3
+ }
4
+ export function resolveStreamMode(ctx, node) {
5
+ const grans = [
6
+ ...(ctx.outputProcessors ?? []).map((p) => p.streamGranularity ?? 'turn'),
7
+ ...(ctx.validationPolicies ?? []).map((p) => p.streamGranularity ?? 'turn'),
8
+ ];
9
+ if (nodeHasWholeAnswerGroundingGate(ctx, node))
10
+ grans.push('turn');
11
+ if (grans.includes('turn'))
12
+ return 'turn';
13
+ if (grans.includes('sentence'))
14
+ return 'sentence';
15
+ return 'token';
16
+ }
@@ -0,0 +1,26 @@
1
+ import type { TurnControl } from '../../../types/channel.js';
2
+ import type { RunContext } from '../../../types/run-context.js';
3
+ import type { StreamMode } from './mode.js';
4
+ export interface TokenSource {
5
+ [Symbol.asyncIterator](): AsyncIterator<{
6
+ delta: string;
7
+ }>;
8
+ }
9
+ export interface GateOutcome {
10
+ blocked: boolean;
11
+ text: string;
12
+ reason?: string;
13
+ control?: TurnControl;
14
+ confidence?: number;
15
+ }
16
+ export declare function speakGated(args: {
17
+ ctx: RunContext;
18
+ mode: StreamMode;
19
+ turnId: string;
20
+ source: TokenSource;
21
+ runGate: (fullOrSentence: string, final: boolean) => Promise<GateOutcome>;
22
+ }): Promise<{
23
+ text: string;
24
+ control?: TurnControl;
25
+ confidence?: number;
26
+ }>;
@@ -0,0 +1,103 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { SentenceAggregator } from './SentenceAggregator.js';
3
+ function emitMessage(ctx, id, text) {
4
+ ctx.emit({ type: 'text-start', id });
5
+ ctx.emit({ type: 'text-delta', id, delta: text });
6
+ ctx.emit({ type: 'text-end', id });
7
+ }
8
+ async function emitBlockedSafeMessage(ctx, turnId, started, outcome) {
9
+ if (started) {
10
+ ctx.emit({
11
+ type: 'text-cancel',
12
+ id: turnId,
13
+ reason: outcome.reason ?? 'blocked',
14
+ });
15
+ }
16
+ const safeId = randomUUID();
17
+ emitMessage(ctx, safeId, outcome.text);
18
+ return {
19
+ text: outcome.text,
20
+ control: outcome.control,
21
+ confidence: outcome.confidence,
22
+ };
23
+ }
24
+ export async function speakGated(args) {
25
+ const { ctx, mode, turnId, source, runGate } = args;
26
+ if (mode === 'turn') {
27
+ let full = '';
28
+ try {
29
+ for await (const { delta } of source) {
30
+ full += delta;
31
+ }
32
+ }
33
+ catch (err) {
34
+ const message = err instanceof Error ? err.message : String(err);
35
+ ctx.emit({ type: 'error', error: message });
36
+ throw err instanceof Error ? err : new Error(message);
37
+ }
38
+ const decision = await runGate(full, true);
39
+ emitMessage(ctx, turnId, decision.text);
40
+ return {
41
+ text: decision.text,
42
+ control: decision.control,
43
+ confidence: decision.confidence,
44
+ };
45
+ }
46
+ const agg = new SentenceAggregator();
47
+ let started = false;
48
+ let emitted = '';
49
+ const openOnce = () => {
50
+ if (!started) {
51
+ ctx.emit({ type: 'text-start', id: turnId });
52
+ started = true;
53
+ }
54
+ };
55
+ const emitCleared = (chunk) => {
56
+ openOnce();
57
+ ctx.emit({ type: 'text-delta', id: turnId, delta: chunk });
58
+ emitted += chunk;
59
+ };
60
+ const gateSentence = async (sentence, final) => {
61
+ const decision = await runGate(sentence.trim(), final);
62
+ if (decision.blocked) {
63
+ return emitBlockedSafeMessage(ctx, turnId, started, decision);
64
+ }
65
+ emitCleared(sentence);
66
+ return null;
67
+ };
68
+ try {
69
+ for await (const { delta } of source) {
70
+ if (mode === 'token') {
71
+ openOnce();
72
+ ctx.emit({ type: 'text-delta', id: turnId, delta });
73
+ emitted += delta;
74
+ continue;
75
+ }
76
+ for (const sentence of agg.push(delta)) {
77
+ const blocked = await gateSentence(sentence, false);
78
+ if (blocked)
79
+ return blocked;
80
+ }
81
+ }
82
+ if (mode === 'sentence') {
83
+ const tail = agg.flush();
84
+ if (tail) {
85
+ const blocked = await gateSentence(tail, true);
86
+ if (blocked)
87
+ return blocked;
88
+ }
89
+ }
90
+ if (started) {
91
+ ctx.emit({ type: 'text-end', id: turnId });
92
+ }
93
+ return { text: emitted };
94
+ }
95
+ catch (err) {
96
+ const message = err instanceof Error ? err.message : String(err);
97
+ ctx.emit({ type: 'error', error: message });
98
+ if (started) {
99
+ ctx.emit({ type: 'text-cancel', id: turnId, reason: message });
100
+ }
101
+ throw err instanceof Error ? err : new Error(message);
102
+ }
103
+ }
@@ -31,6 +31,8 @@ export interface TurnResult {
31
31
  interrupted?: boolean;
32
32
  truncateAt?: number;
33
33
  confidence?: number;
34
+ /** Native realtime post-hoc gate: provider audio already played; gate is advisory only. */
35
+ gateScope?: 'advisory';
34
36
  }
35
37
  export type UserSignal = {
36
38
  type: 'message';
@@ -37,6 +37,8 @@ export interface OutputProcessor {
37
37
  id: string;
38
38
  name?: string;
39
39
  description?: string;
40
+ /** Absent ⇒ `turn` (buffered, safe). Streaming is an explicit opt-in by the gate author. */
41
+ streamGranularity?: 'sentence' | 'turn';
40
42
  process: (args: {
41
43
  text: string;
42
44
  messages: ModelMessage[];
@@ -69,8 +71,19 @@ export interface AgentCapabilityDescriptor {
69
71
  doesNotHandle?: string[];
70
72
  }
71
73
  export type AgentStreamPart = {
74
+ type: 'text-start';
75
+ id: string;
76
+ } | {
72
77
  type: 'text-delta';
73
- text: string;
78
+ id: string;
79
+ delta: string;
80
+ } | {
81
+ type: 'text-end';
82
+ id: string;
83
+ } | {
84
+ type: 'text-cancel';
85
+ id: string;
86
+ reason: string;
74
87
  } | {
75
88
  type: 'tool-call';
76
89
  toolName: string;
@@ -6,8 +6,19 @@ import type { ChoiceOption } from './selection.js';
6
6
  * does not include `{ type: 'interactive' }`.
7
7
  */
8
8
  export type HarnessStreamPart = {
9
+ type: 'text-start';
10
+ id: string;
11
+ } | {
9
12
  type: 'text-delta';
10
- text: string;
13
+ id: string;
14
+ delta: string;
15
+ } | {
16
+ type: 'text-end';
17
+ id: string;
18
+ } | {
19
+ type: 'text-cancel';
20
+ id: string;
21
+ reason: string;
11
22
  } | {
12
23
  type: 'tool-call';
13
24
  toolName: string;
@@ -56,6 +67,16 @@ export type HarnessStreamPart = {
56
67
  prompt: string;
57
68
  } | {
58
69
  type: 'turn-end';
70
+ } | {
71
+ type: 'pipeline-validation-block';
72
+ rationale: string;
73
+ userFacingMessage?: string;
74
+ } | {
75
+ type: 'safety-blocked';
76
+ moderator: string;
77
+ rationale: string;
78
+ userFacingMessage: string;
79
+ handlerOutcome?: 'queued' | 'connected' | 'failed';
59
80
  } | {
60
81
  type: 'error';
61
82
  error: string;
@@ -216,9 +216,20 @@ export type HarnessStreamPart = {
216
216
  type: 'input';
217
217
  text: string;
218
218
  userId?: string;
219
+ } | {
220
+ type: 'text-start';
221
+ id: string;
219
222
  } | {
220
223
  type: 'text-delta';
221
- text: string;
224
+ id: string;
225
+ delta: string;
226
+ } | {
227
+ type: 'text-end';
228
+ id: string;
229
+ } | {
230
+ type: 'text-cancel';
231
+ id: string;
232
+ reason: string;
222
233
  } | {
223
234
  type: 'channel-switched';
224
235
  from: ChannelId;
package/package.json CHANGED
@@ -6,7 +6,7 @@
6
6
  "url": "git+https://github.com/kuralle/kuralle-agents.git",
7
7
  "directory": "packages/kuralle-core"
8
8
  },
9
- "version": "0.3.20",
9
+ "version": "0.4.0",
10
10
  "description": "A framework for structured conversational AI agents",
11
11
  "publishConfig": {
12
12
  "access": "public"
@@ -97,7 +97,7 @@
97
97
  "dotenv": "^16.4.0",
98
98
  "typescript": "^5.3.0",
99
99
  "zod": "^3.23.0",
100
- "@kuralle-agents/realtime-audio": "0.3.20"
100
+ "@kuralle-agents/realtime-audio": "0.4.0"
101
101
  },
102
102
  "dependencies": {
103
103
  "chrono-node": "^2.6.0",