@kuralle-agents/core 0.6.1 → 0.7.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.
Files changed (45) hide show
  1. package/README.md +2 -4
  2. package/dist/capabilities/index.d.ts +0 -3
  3. package/dist/capabilities/index.js +0 -2
  4. package/dist/flow/classifyControl.js +4 -0
  5. package/dist/flow/collectDigression.js +0 -1
  6. package/dist/flow/controlEvaluator.js +3 -0
  7. package/dist/flow/flowControlTools.js +1 -0
  8. package/dist/index.d.ts +2 -2
  9. package/dist/index.js +1 -1
  10. package/dist/runtime/Runtime.d.ts +3 -1
  11. package/dist/runtime/Runtime.js +3 -1
  12. package/dist/runtime/agentReply.d.ts +2 -1
  13. package/dist/runtime/agentReply.js +14 -3
  14. package/dist/runtime/channels/TextDriver.d.ts +1 -0
  15. package/dist/runtime/channels/TextDriver.js +31 -18
  16. package/dist/runtime/channels/VoiceDriver.d.ts +1 -0
  17. package/dist/runtime/channels/VoiceDriver.js +5 -1
  18. package/dist/runtime/channels/streaming/hostControlSpeak.d.ts +30 -0
  19. package/dist/runtime/channels/streaming/hostControlSpeak.js +80 -0
  20. package/dist/runtime/deriveAgent.d.ts +10 -1
  21. package/dist/runtime/deriveAgent.js +63 -13
  22. package/dist/runtime/dispatchMode.d.ts +5 -0
  23. package/dist/runtime/dispatchMode.js +18 -0
  24. package/dist/runtime/hostClassifyAdapter.d.ts +2 -0
  25. package/dist/runtime/hostClassifyAdapter.js +21 -0
  26. package/dist/runtime/hostControlGuard.d.ts +23 -0
  27. package/dist/runtime/hostControlGuard.js +67 -0
  28. package/dist/runtime/hostControlTools.d.ts +12 -0
  29. package/dist/runtime/hostControlTools.js +89 -0
  30. package/dist/runtime/hostLoop.d.ts +4 -1
  31. package/dist/runtime/hostLoop.js +122 -34
  32. package/dist/runtime/select.d.ts +17 -4
  33. package/dist/runtime/select.js +107 -76
  34. package/dist/tools/enterFlow.d.ts +14 -0
  35. package/dist/tools/enterFlow.js +31 -0
  36. package/dist/types/channel.d.ts +12 -0
  37. package/dist/types/route.d.ts +3 -3
  38. package/guides/AGENTS.md +7 -10
  39. package/guides/FLOWS.md +0 -4
  40. package/guides/RUNTIME.md +2 -2
  41. package/package.json +2 -2
  42. package/dist/capabilities/HandoffCapability.d.ts +0 -18
  43. package/dist/capabilities/HandoffCapability.js +0 -69
  44. package/dist/capabilities/TriageCapability.d.ts +0 -15
  45. package/dist/capabilities/TriageCapability.js +0 -60
package/README.md CHANGED
@@ -99,17 +99,15 @@ Rule of thumb: if you're pasting more than ~20 lines of procedure into a system
99
99
  ```ts
100
100
  const triage = defineAgent({
101
101
  id: 'triage',
102
- instructions: 'Route to the right specialist.',
103
102
  model: openai('gpt-4o-mini'),
104
103
  routes: [
105
104
  { agent: 'billing', when: 'billing question' },
106
- { agent: 'support', when: 'support request' },
105
+ { agent: 'support', when: 'support request or anything else' },
107
106
  ],
108
- routing: { mode: 'structured', default: 'support' },
109
107
  });
110
108
  ```
111
109
 
112
- `mode: 'structured'` routes via schema the routing decision never surfaces to the user.
110
+ With only `routes`/`agents` and no answering surface (no `instructions`/`flows`/`tools`), `triage` derives as a **pure dispatcher**: it silently classifies and routes. The decision is model-reasoned over the `when` descriptions and never surfaces to the user. Model every fallback as a normal route with a semantic `when` (e.g. "or anything else") — there is no `routing.default`. Optionally set `routing: { model }` to pick the control-reasoning model.
113
111
 
114
112
  ## Sessions
115
113
 
@@ -141,11 +141,8 @@ export declare class CapabilityHost {
141
141
  /** Number of registered capabilities. */
142
142
  get capabilityCount(): number;
143
143
  }
144
- export { TriageCapability } from './TriageCapability.js';
145
144
  export { ExtractionCapability } from './ExtractionCapability.js';
146
145
  export type { ExtractionCapabilityConfig } from './ExtractionCapability.js';
147
- export { HandoffCapability } from './HandoffCapability.js';
148
- export type { HandoffTarget } from './HandoffCapability.js';
149
146
  export { GuardrailCapability } from './GuardrailCapability.js';
150
147
  export { AutoRetrieveCapability } from './AutoRetrieveCapability.js';
151
148
  export type { AutoRetrieveCapabilityConfig, RetrieveProvider } from './AutoRetrieveCapability.js';
@@ -116,9 +116,7 @@ function assemblePromptSections(basePrompt, sections) {
116
116
  return parts.join('\n\n');
117
117
  }
118
118
  // ─── Re-exports ──────────────────────────────────────────────────────────────
119
- export { TriageCapability } from './TriageCapability.js';
120
119
  export { ExtractionCapability } from './ExtractionCapability.js';
121
- export { HandoffCapability } from './HandoffCapability.js';
122
120
  export { GuardrailCapability } from './GuardrailCapability.js';
123
121
  export { AutoRetrieveCapability } from './AutoRetrieveCapability.js';
124
122
  export { PassThroughRefinement } from './refinement/PassThrough.js';
@@ -1,5 +1,6 @@
1
1
  import { isHandoffResult } from '../tools/handoff.js';
2
2
  import { isFinalResult } from '../tools/final.js';
3
+ import { isEnterFlowResult } from '../tools/enterFlow.js';
3
4
  import { isEscalateResult, isRecoverResult } from '../tools/controlResults.js';
4
5
  export function classifyControl(result) {
5
6
  if (isHandoffResult(result)) {
@@ -9,6 +10,9 @@ export function classifyControl(result) {
9
10
  reason: result.reason,
10
11
  };
11
12
  }
13
+ if (isEnterFlowResult(result)) {
14
+ return { type: 'enterFlow', flowName: result.flowName, reason: result.reason };
15
+ }
12
16
  if (isFinalResult(result)) {
13
17
  return { type: 'end', reason: result.text };
14
18
  }
@@ -44,7 +44,6 @@ export async function runCollectDigression(options) {
44
44
  agent,
45
45
  run,
46
46
  model: agent.routing?.model ?? ctx.controlModel,
47
- alwaysRoute: agent.routing?.always === true,
48
47
  excludeFlowNames: [activeFlowName],
49
48
  });
50
49
  if (selection.kind === 'route') {
@@ -9,6 +9,9 @@ function controlToTransition(control) {
9
9
  return { kind: 'escalate', reason: control.reason };
10
10
  case 'recover':
11
11
  return { kind: 'end', reason: control.reason ?? 'error_degraded' };
12
+ case 'enterFlow':
13
+ // enter_flow is host-turn-only; never injected into in-flow reply nodes.
14
+ throw new Error(`enter_flow control reached the in-flow evaluator (flow "${control.flowName}") — it is host-only`);
12
15
  }
13
16
  }
14
17
  export async function evaluateReplyControl(signal) {
@@ -2,6 +2,7 @@
2
2
  export const FLOW_TRANSITION_CONTROL_TOOL_NAMES = new Set([
3
3
  'handoff',
4
4
  'transfer_to_agent',
5
+ 'enter_flow',
5
6
  'final',
6
7
  'escalate',
7
8
  'recover',
package/dist/index.d.ts CHANGED
@@ -60,8 +60,8 @@ export type { HandoffInputFilter, HandoffInputData, HandoffInputResult, } from '
60
60
  export { DefaultToolExecutor, DefaultConversationState, DefaultConversationEventLog, DefaultAgentStateController, createFoundation, } from './foundation/index.js';
61
61
  export type { ToolExecutor, ConversationState, ConversationEventLog, ConversationEvent, AgentStateController, Foundation, FoundationConfig, } from './foundation/index.js';
62
62
  export type * from './types/index.js';
63
- export { CapabilityHost, TriageCapability, ExtractionCapability, HandoffCapability, GuardrailCapability, AutoRetrieveCapability, PassThroughRefinement, PassThroughValidation, toGeminiDeclarations, toAISDKTools, } from './capabilities/index.js';
64
- export type { Capability, CapabilityAction, ExtractionToolResponseEnvelope, FlowReconfigureTransition, ToolDeclaration, PromptSection as CapabilityPromptSection, GeminiFunctionDeclaration, ExtractionCapabilityConfig, HandoffTarget, RetrieveProvider, AutoRetrieveCapabilityConfig, RefinementCapability, RefineInput, RefineDecision, ValidationCapability, ValidateInput, ValidateDecision, SourceRef, } from './capabilities/index.js';
63
+ export { CapabilityHost, ExtractionCapability, GuardrailCapability, AutoRetrieveCapability, PassThroughRefinement, PassThroughValidation, toGeminiDeclarations, toAISDKTools, } from './capabilities/index.js';
64
+ export type { Capability, CapabilityAction, ExtractionToolResponseEnvelope, FlowReconfigureTransition, ToolDeclaration, PromptSection as CapabilityPromptSection, GeminiFunctionDeclaration, ExtractionCapabilityConfig, RetrieveProvider, AutoRetrieveCapabilityConfig, RefinementCapability, RefineInput, RefineDecision, ValidationCapability, ValidateInput, ValidateDecision, SourceRef, } from './capabilities/index.js';
65
65
  export type { EscalationOutcome, EscalationReason } from './escalation/types.js';
66
66
  export { filterAuditEntries } from './audit/index.js';
67
67
  export type { AuditConfig, AuditEntryBase, AuditEntryType, AuditListOptions, AuditReplayOptions, ConversationAuditEntry, ConversationAuditLog, } from './audit/types.js';
package/dist/index.js CHANGED
@@ -36,7 +36,7 @@ export { DEFAULT_CONTEXT_BUDGET, VOICE_CONTEXT_BUDGET, computeMessageHistoryBudg
36
36
  export { TokenAccumulator } from './runtime/TokenAccumulator.js';
37
37
  export { handoffFilters, removeToolHistory, keepRecentMessages, removeKeys, composeFilters, } from './runtime/handoffFilters.js';
38
38
  export { DefaultToolExecutor, DefaultConversationState, DefaultConversationEventLog, DefaultAgentStateController, createFoundation, } from './foundation/index.js';
39
- export { CapabilityHost, TriageCapability, ExtractionCapability, HandoffCapability, GuardrailCapability, AutoRetrieveCapability, PassThroughRefinement, PassThroughValidation, toGeminiDeclarations, toAISDKTools, } from './capabilities/index.js';
39
+ export { CapabilityHost, ExtractionCapability, GuardrailCapability, AutoRetrieveCapability, PassThroughRefinement, PassThroughValidation, toGeminiDeclarations, toAISDKTools, } from './capabilities/index.js';
40
40
  export { filterAuditEntries } from './audit/index.js';
41
41
  export { defineAgent, defineFlow, reply, collect, action, decide, confirmGate, } from './authoring/index.js';
42
42
  export { defineTool } from './types/effectTool.js';
@@ -10,7 +10,7 @@ import type { TurnHandle } from '../types/stream.js';
10
10
  import type { SignalDelivery } from './durable/types.js';
11
11
  import type { ResolvedSelection } from '../types/selection.js';
12
12
  import type { ConversationOutcome, ConversationOutcomeMarkedBy } from '../outcomes/types.js';
13
- import type { selectHostTarget } from './select.js';
13
+ import type { classifyHostTarget, selectHostTarget } from './select.js';
14
14
  import type { KnowledgeProviderConfig } from '../types/voice.js';
15
15
  import type { MemoryService as V1MemoryService } from '../memory/MemoryService.js';
16
16
  import type { PersistentMemoryStore } from '../memory/blocks/types.js';
@@ -23,6 +23,8 @@ export interface HarnessConfig {
23
23
  terminalHandoffTargets?: string[];
24
24
  hooks?: Hooks;
25
25
  voiceMode?: boolean;
26
+ hostClassify?: typeof classifyHostTarget;
27
+ /** @deprecated Use hostClassify — test injection adapter for HostSelection stubs. */
26
28
  hostSelect?: typeof selectHostTarget;
27
29
  tools?: Record<string, AnyTool>;
28
30
  knowledge?: KnowledgeProviderConfig;
@@ -10,6 +10,7 @@ import { wireAgentSkills } from '../skills/wireAgentSkills.js';
10
10
  import { hostLoop } from './hostLoop.js';
11
11
  import { isDegradableRuntimeError } from '../flow/degradableErrors.js';
12
12
  import { SAFE_DEGRADED_MESSAGE } from '../flow/degrade.js';
13
+ import { adaptHostSelect } from './hostClassifyAdapter.js';
13
14
  import { openRun } from './openRun.js';
14
15
  import { closeRun } from './closeRun.js';
15
16
  import { SessionRunStore } from './durable/SessionRunStore.js';
@@ -166,7 +167,8 @@ export class Runtime {
166
167
  run: runCtx.runState,
167
168
  driver,
168
169
  ctx: runCtx,
169
- select: this.config.hostSelect,
170
+ classify: this.config.hostClassify ??
171
+ (this.config.hostSelect ? adaptHostSelect(this.config.hostSelect) : undefined),
170
172
  });
171
173
  if (loopResult.kind === 'handoff') {
172
174
  if (this.terminalHandoffTargets.has(loopResult.to)) {
@@ -1,3 +1,4 @@
1
1
  import type { AgentConfig } from '../types/agentConfig.js';
2
2
  import type { ReplyNode } from '../types/flow.js';
3
- export declare function buildAgentReplyNode(agent: AgentConfig): ReplyNode;
3
+ import type { RunState } from './durable/types.js';
4
+ export declare function buildAgentReplyNode(agent: AgentConfig, run?: RunState): ReplyNode;
@@ -1,13 +1,24 @@
1
1
  import { buildToolSet } from '../tools/effect/defineTool.js';
2
- export function buildAgentReplyNode(agent) {
2
+ import { deriveAgentShape } from './deriveAgent.js';
3
+ import { buildHostControlTools } from './hostControlTools.js';
4
+ export function buildAgentReplyNode(agent, run) {
5
+ const shape = deriveAgentShape(agent);
6
+ const tools = { ...agent.tools };
7
+ if (shape.isAnsweringAgent && run) {
8
+ Object.assign(tools, buildHostControlTools(agent, run));
9
+ }
3
10
  const label = agent.name ?? agent.id;
4
11
  const instructions = agent.instructions ??
5
- `You are ${label}. Help the user concisely. Do not mention internal routing or flows.`;
12
+ (shape.isAnsweringAgent
13
+ ? `You are ${label}. Help the user concisely. Do not mention internal routing or flows. ` +
14
+ 'If the user wants a specialist or a procedure, CALL the control tool (enter_flow or transfer_to_agent) — ' +
15
+ 'do not first say a filler or acknowledgement like "Sure" or "One moment".'
16
+ : '');
6
17
  return {
7
18
  kind: 'reply',
8
19
  id: `${agent.id}__host`,
9
20
  instructions,
10
- tools: agent.tools ? buildToolSet(agent.tools) : undefined,
21
+ tools: Object.keys(tools).length > 0 ? buildToolSet(tools) : undefined,
11
22
  model: agent.model,
12
23
  };
13
24
  }
@@ -9,6 +9,7 @@ export interface TextDriverConfig {
9
9
  maxSteps?: number;
10
10
  }
11
11
  export declare class TextDriver implements ChannelDriver {
12
+ readonly outputCapability: "kuralle-controlled-text";
12
13
  private readonly toolDefs;
13
14
  private readonly maxSteps;
14
15
  constructor(config?: TextDriverConfig);
@@ -6,12 +6,13 @@ 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';
9
+ import { speakWithHostControl } from './streaming/hostControlSpeak.js';
10
10
  import { resolveStreamMode } from './streaming/mode.js';
11
11
  import { appendGatherBlocks, resolveNodeGatherScope, runGatherPhase } from '../grounding/index.js';
12
12
  import { isFlowTransitionControlTool } from '../../flow/flowControlTools.js';
13
13
  import { resolveStructuredDecide } from '../../flow/choiceMatch.js';
14
14
  export class TextDriver {
15
+ outputCapability = 'kuralle-controlled-text';
15
16
  toolDefs;
16
17
  maxSteps;
17
18
  constructor(config = {}) {
@@ -108,24 +109,36 @@ export class TextDriver {
108
109
  }
109
110
  },
110
111
  };
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
- });
112
+ const runGate = async (text, _final) => {
113
+ const r = await applyPostTurnPolicies(ctx, text, toolCallsMade, gather.citations ?? []);
114
+ return {
115
+ blocked: !r.proceed,
116
+ text: r.proceed ? r.text : (r.blockedMessage ?? r.text),
117
+ reason: r.control?.reason,
118
+ control: r.control,
119
+ confidence: r.confidence,
120
+ };
121
+ };
122
+ const speakFn = node.hostControl
123
+ ? speakWithHostControl({
124
+ ctx,
125
+ mode,
126
+ turnId,
127
+ source,
128
+ runGate,
129
+ dispatchMode: node.hostControl.dispatchMode,
130
+ getToolControl: () => out.control,
131
+ })
132
+ : (await import('./streaming/speakGated.js')).speakGated({
133
+ ctx,
134
+ mode,
135
+ turnId,
136
+ source,
137
+ runGate,
138
+ });
139
+ const spoken = await speakFn;
127
140
  out.text = spoken.text;
128
- out.control = spoken.control;
141
+ out.control = spoken.control ?? out.control;
129
142
  out.confidence = spoken.confidence;
130
143
  ctx.emit({ type: 'turn-end' });
131
144
  return out;
@@ -12,6 +12,7 @@ export interface VoiceDriverConfig {
12
12
  maxSteps?: number;
13
13
  }
14
14
  export declare class VoiceDriver implements ChannelDriver {
15
+ readonly outputCapability: "native-realtime";
15
16
  private readonly client;
16
17
  private readonly toolDefs;
17
18
  private readonly maxSteps;
@@ -34,6 +34,7 @@ function emitNativeRealtimePostHocGate(ctx, client, gate) {
34
34
  client.requestResponse?.(gate.safeText);
35
35
  }
36
36
  export class VoiceDriver {
37
+ outputCapability = 'native-realtime';
37
38
  client;
38
39
  toolDefs;
39
40
  maxSteps;
@@ -128,7 +129,10 @@ export class VoiceDriver {
128
129
  transcript.close('complete');
129
130
  const spoken = await speakPromise;
130
131
  out.text = spoken.text;
131
- out.control = spoken.control;
132
+ // Preserve host-control raised during provider tool execution (enter_flow /
133
+ // transfer_to_agent); the post-hoc gate must not clobber it (else native
134
+ // realtime silently drops valid routing). Same fix as TextDriver.
135
+ out.control = spoken.control ?? out.control;
132
136
  out.confidence = spoken.confidence;
133
137
  if (postHocGate) {
134
138
  // Advisory post-hoc gate: provider audio may already have played; correction does not un-speak it.
@@ -0,0 +1,30 @@
1
+ import type { TurnControl } from '../../../types/channel.js';
2
+ import type { RunContext } from '../../../types/run-context.js';
3
+ import { type TokenSource, type GateOutcome } from './speakGated.js';
4
+ import type { StreamMode } from './mode.js';
5
+ /**
6
+ * Streaming dispatch gate. Its only job is to control EMISSION per dispatch mode
7
+ * and surface the model's own control tool — it does NOT consult the host guard.
8
+ * The guard has a single owner (`hostLoop`): when this returns empty text and no
9
+ * control, `hostLoop` runs the guard exactly once. Keeping the guard out of here
10
+ * avoids double-evaluation and keeps guard telemetry attributable.
11
+ *
12
+ * - relaxed: stream live; if the model's own control tool fires, cancel any
13
+ * streamed text and return that control.
14
+ * - strict: emit NOTHING until the model's intent is known — buffer until the
15
+ * first substantive token (answering → flush + stream the rest live) or source
16
+ * end (no answer → return empty; `hostLoop` then guards with no leak).
17
+ */
18
+ export declare function speakWithHostControl(args: {
19
+ ctx: RunContext;
20
+ mode: StreamMode;
21
+ turnId: string;
22
+ source: TokenSource;
23
+ runGate: (fullOrSentence: string, final: boolean) => Promise<GateOutcome>;
24
+ dispatchMode: 'strict' | 'relaxed';
25
+ getToolControl: () => TurnControl | undefined;
26
+ }): Promise<{
27
+ text: string;
28
+ control?: TurnControl;
29
+ confidence?: number;
30
+ }>;
@@ -0,0 +1,80 @@
1
+ import { speakGated } from './speakGated.js';
2
+ /**
3
+ * Streaming dispatch gate. Its only job is to control EMISSION per dispatch mode
4
+ * and surface the model's own control tool — it does NOT consult the host guard.
5
+ * The guard has a single owner (`hostLoop`): when this returns empty text and no
6
+ * control, `hostLoop` runs the guard exactly once. Keeping the guard out of here
7
+ * avoids double-evaluation and keeps guard telemetry attributable.
8
+ *
9
+ * - relaxed: stream live; if the model's own control tool fires, cancel any
10
+ * streamed text and return that control.
11
+ * - strict: emit NOTHING until the model's intent is known — buffer until the
12
+ * first substantive token (answering → flush + stream the rest live) or source
13
+ * end (no answer → return empty; `hostLoop` then guards with no leak).
14
+ */
15
+ export async function speakWithHostControl(args) {
16
+ const { ctx, mode, turnId, source, runGate, dispatchMode, getToolControl } = args;
17
+ if (dispatchMode === 'strict') {
18
+ const it = source[Symbol.asyncIterator]();
19
+ const buffered = [];
20
+ let answered = false;
21
+ while (!getToolControl()) {
22
+ const r = await it.next();
23
+ if (r.done) {
24
+ break;
25
+ }
26
+ buffered.push(r.value);
27
+ if (r.value.delta.trim().length > 0) {
28
+ answered = true;
29
+ break;
30
+ }
31
+ }
32
+ const toolControl = getToolControl();
33
+ if (toolControl) {
34
+ return { text: '', control: toolControl };
35
+ }
36
+ // No substantive text → emit nothing; hostLoop owns the empty-turn guard.
37
+ if (!answered) {
38
+ return { text: '', control: undefined };
39
+ }
40
+ // Answering: flush the buffered prefix, then stream the remainder live.
41
+ const flushSource = {
42
+ async *[Symbol.asyncIterator]() {
43
+ for (const chunk of buffered) {
44
+ yield chunk;
45
+ }
46
+ let cur = await it.next();
47
+ while (!cur.done) {
48
+ if (getToolControl()) {
49
+ return;
50
+ }
51
+ yield cur.value;
52
+ cur = await it.next();
53
+ }
54
+ },
55
+ };
56
+ return speakGated({ ctx, mode, turnId, source: flushSource, runGate });
57
+ }
58
+ // Relaxed: stream live; stop if the model's own control tool fires.
59
+ let toolControl;
60
+ const wrappedSource = {
61
+ async *[Symbol.asyncIterator]() {
62
+ for await (const chunk of source) {
63
+ toolControl = getToolControl();
64
+ if (toolControl) {
65
+ return;
66
+ }
67
+ yield chunk;
68
+ }
69
+ },
70
+ };
71
+ const spoken = await speakGated({ ctx, mode, turnId, source: wrappedSource, runGate });
72
+ toolControl = getToolControl();
73
+ if (toolControl) {
74
+ if (spoken.text) {
75
+ ctx.emit({ type: 'text-cancel', id: turnId, reason: 'host-control' });
76
+ }
77
+ return { text: '', control: toolControl };
78
+ }
79
+ return spoken;
80
+ }
@@ -6,5 +6,14 @@ export interface DerivedAgentCapabilities {
6
6
  hasHandoffs: boolean;
7
7
  precedence: 'routes' | 'flows' | 'free';
8
8
  }
9
+ export interface AgentShape {
10
+ hasDispatchTargets: boolean;
11
+ hasLocalProcedure: boolean;
12
+ hasLocalAnsweringSurface: boolean;
13
+ isAnsweringAgent: boolean;
14
+ isPureDispatcher: boolean;
15
+ }
16
+ export declare function hasLocalAnsweringSurface(agent: AgentConfig): boolean;
17
+ export declare function hasDispatchTargets(agent: AgentConfig): boolean;
18
+ export declare function deriveAgentShape(agent: AgentConfig): AgentShape;
9
19
  export declare function deriveAgentCapabilities(agent: AgentConfig): DerivedAgentCapabilities;
10
- export declare function shouldRunHostSelector(agent: AgentConfig, activeFlow?: string, alwaysRoute?: boolean): boolean;
@@ -1,8 +1,68 @@
1
+ function hasPopulatedInstructions(instructions) {
2
+ if (instructions === undefined) {
3
+ return false;
4
+ }
5
+ if (typeof instructions === 'string') {
6
+ return instructions.trim().length > 0;
7
+ }
8
+ return true;
9
+ }
10
+ export function hasLocalAnsweringSurface(agent) {
11
+ if (hasPopulatedInstructions(agent.instructions)) {
12
+ return true;
13
+ }
14
+ if (agent.tools && Object.keys(agent.tools).length > 0) {
15
+ return true;
16
+ }
17
+ if (agent.globalTools && Object.keys(agent.globalTools).length > 0) {
18
+ return true;
19
+ }
20
+ if (agent.knowledge) {
21
+ return true;
22
+ }
23
+ if (agent.memory) {
24
+ return true;
25
+ }
26
+ if (agent.skills) {
27
+ return true;
28
+ }
29
+ if (agent.workspace) {
30
+ return true;
31
+ }
32
+ return false;
33
+ }
34
+ export function hasDispatchTargets(agent) {
35
+ const routes = agent.routes ?? [];
36
+ if (routes.some((route) => route.agent || route.flow)) {
37
+ return true;
38
+ }
39
+ if ((agent.agents?.length ?? 0) > 0) {
40
+ return true;
41
+ }
42
+ if ((agent.handoffs?.length ?? 0) > 0) {
43
+ return true;
44
+ }
45
+ return false;
46
+ }
47
+ export function deriveAgentShape(agent) {
48
+ const hasLocalProcedure = (agent.flows?.length ?? 0) > 0;
49
+ const answeringSurface = hasLocalAnsweringSurface(agent);
50
+ const dispatchTargets = hasDispatchTargets(agent);
51
+ const isAnsweringAgent = hasLocalProcedure || answeringSurface;
52
+ const isPureDispatcher = dispatchTargets && !isAnsweringAgent;
53
+ return {
54
+ hasDispatchTargets: dispatchTargets,
55
+ hasLocalProcedure,
56
+ hasLocalAnsweringSurface: answeringSurface,
57
+ isAnsweringAgent,
58
+ isPureDispatcher,
59
+ };
60
+ }
1
61
  export function deriveAgentCapabilities(agent) {
62
+ const shape = deriveAgentShape(agent);
2
63
  const hasRoutes = (agent.routes?.length ?? 0) > 0;
3
- const hasFlows = (agent.flows?.length ?? 0) > 0;
64
+ const hasFlows = shape.hasLocalProcedure;
4
65
  const hasHandoffs = (agent.agents?.length ?? 0) > 0 || (agent.handoffs?.length ?? 0) > 0;
5
- const hasFreeConversation = true;
6
66
  const precedence = hasRoutes
7
67
  ? 'routes'
8
68
  : hasFlows
@@ -11,18 +71,8 @@ export function deriveAgentCapabilities(agent) {
11
71
  return {
12
72
  hasRoutes,
13
73
  hasFlows,
14
- hasFreeConversation,
74
+ hasFreeConversation: shape.isAnsweringAgent,
15
75
  hasHandoffs,
16
76
  precedence,
17
77
  };
18
78
  }
19
- export function shouldRunHostSelector(agent, activeFlow, alwaysRoute) {
20
- if (activeFlow) {
21
- return false;
22
- }
23
- if (alwaysRoute) {
24
- return true;
25
- }
26
- const { hasRoutes, hasFlows } = deriveAgentCapabilities(agent);
27
- return hasRoutes || hasFlows;
28
- }
@@ -0,0 +1,5 @@
1
+ import type { AgentConfig } from '../types/agentConfig.js';
2
+ import type { DriverOutputCapability } from '../types/channel.js';
3
+ export type DispatchMode = 'strict' | 'relaxed';
4
+ export declare function resolveDispatchMode(agent: AgentConfig, capability: DriverOutputCapability): DispatchMode;
5
+ export declare function isAdvisoryDispatch(capability: DriverOutputCapability): boolean;
@@ -0,0 +1,18 @@
1
+ export function resolveDispatchMode(agent, capability) {
2
+ if (agent.routing?.dispatch === 'strict') {
3
+ return 'strict';
4
+ }
5
+ switch (capability) {
6
+ case 'kuralle-controlled-text':
7
+ return 'relaxed';
8
+ case 'kuralle-controlled-tts':
9
+ return 'strict';
10
+ case 'native-realtime':
11
+ return 'strict';
12
+ default:
13
+ return 'relaxed';
14
+ }
15
+ }
16
+ export function isAdvisoryDispatch(capability) {
17
+ return capability === 'native-realtime';
18
+ }
@@ -0,0 +1,2 @@
1
+ import type { classifyHostTarget } from './select.js';
2
+ export declare function adaptHostSelect(select: typeof import('./select.js').selectHostTarget): typeof classifyHostTarget;
@@ -0,0 +1,21 @@
1
+ export function adaptHostSelect(select) {
2
+ return async (options) => {
3
+ const selection = await select({
4
+ agent: options.agent,
5
+ run: options.run,
6
+ model: options.model,
7
+ excludeFlowNames: options.excludeFlowNames,
8
+ });
9
+ if (selection.kind === 'keep') {
10
+ return { action: 'keep', confidence: 1 };
11
+ }
12
+ if (selection.kind === 'enterFlow') {
13
+ return { action: 'enterFlow', flowName: selection.flow.name };
14
+ }
15
+ return {
16
+ action: 'transfer',
17
+ targetAgentId: selection.agentId,
18
+ reason: selection.reason,
19
+ };
20
+ };
21
+ }
@@ -0,0 +1,23 @@
1
+ import type { LanguageModel } from 'ai';
2
+ import type { AgentConfig } from '../types/agentConfig.js';
3
+ import type { TurnControl } from '../types/channel.js';
4
+ import type { RunState } from './durable/types.js';
5
+ import { type HostGuardVerdict, type ClassifyHostOptions } from './select.js';
6
+ export type { HostGuardVerdict };
7
+ export declare function startHostControlGuard(options: {
8
+ agent: AgentConfig;
9
+ run: RunState;
10
+ model: LanguageModel;
11
+ classify?: (opts: ClassifyHostOptions) => Promise<HostGuardVerdict>;
12
+ }): Promise<HostGuardVerdict>;
13
+ export declare function isValidControl(control: TurnControl, agent: AgentConfig, run: RunState): boolean;
14
+ export declare function isValidGuardVerdict(verdict: HostGuardVerdict, agent: AgentConfig, run: RunState): boolean;
15
+ export declare function guardVerdictToControl(verdict: HostGuardVerdict): TurnControl | undefined;
16
+ /**
17
+ * Main model control wins when valid. The guard is a forgot-to-route net, NOT a
18
+ * second-guesser: it only overrides when the answering model produced neither a
19
+ * control tool NOR a substantive answer (`mainAnswered`). If the model answered,
20
+ * the answer stands — a guard verdict must not hijack a correct keep answer
21
+ * (which caused observed mis-routes of Q&A turns into flows).
22
+ */
23
+ export declare function resolveHostControl(mainControl: TurnControl | undefined, guardVerdict: HostGuardVerdict | undefined, agent: AgentConfig, run: RunState, mainAnswered: boolean): TurnControl | undefined;