@kuralle-agents/core 0.6.1 → 0.7.1

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 (52) 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 +29 -57
  12. package/dist/runtime/agentReply.d.ts +2 -1
  13. package/dist/runtime/agentReply.js +14 -3
  14. package/dist/runtime/buildAgentToolSurface.d.ts +20 -0
  15. package/dist/runtime/buildAgentToolSurface.js +56 -0
  16. package/dist/runtime/channels/TextDriver.d.ts +1 -0
  17. package/dist/runtime/channels/TextDriver.js +36 -19
  18. package/dist/runtime/channels/VoiceDriver.d.ts +1 -0
  19. package/dist/runtime/channels/VoiceDriver.js +10 -2
  20. package/dist/runtime/channels/streaming/hostControlSpeak.d.ts +30 -0
  21. package/dist/runtime/channels/streaming/hostControlSpeak.js +80 -0
  22. package/dist/runtime/deriveAgent.d.ts +10 -1
  23. package/dist/runtime/deriveAgent.js +63 -13
  24. package/dist/runtime/dispatchMode.d.ts +5 -0
  25. package/dist/runtime/dispatchMode.js +18 -0
  26. package/dist/runtime/grounding/index.d.ts +1 -1
  27. package/dist/runtime/grounding/index.js +1 -1
  28. package/dist/runtime/grounding/knowledge.d.ts +2 -0
  29. package/dist/runtime/grounding/knowledge.js +37 -0
  30. package/dist/runtime/hostClassifyAdapter.d.ts +2 -0
  31. package/dist/runtime/hostClassifyAdapter.js +21 -0
  32. package/dist/runtime/hostControlGuard.d.ts +23 -0
  33. package/dist/runtime/hostControlGuard.js +67 -0
  34. package/dist/runtime/hostControlTools.d.ts +12 -0
  35. package/dist/runtime/hostControlTools.js +89 -0
  36. package/dist/runtime/hostLoop.d.ts +4 -1
  37. package/dist/runtime/hostLoop.js +122 -34
  38. package/dist/runtime/select.d.ts +17 -4
  39. package/dist/runtime/select.js +107 -76
  40. package/dist/tools/enterFlow.d.ts +14 -0
  41. package/dist/tools/enterFlow.js +31 -0
  42. package/dist/types/channel.d.ts +12 -0
  43. package/dist/types/grounding.d.ts +9 -0
  44. package/dist/types/route.d.ts +3 -3
  45. package/guides/AGENTS.md +7 -10
  46. package/guides/FLOWS.md +0 -4
  47. package/guides/RUNTIME.md +2 -2
  48. package/package.json +2 -2
  49. package/dist/capabilities/HandoffCapability.d.ts +0 -18
  50. package/dist/capabilities/HandoffCapability.js +0 -69
  51. package/dist/capabilities/TriageCapability.d.ts +0 -15
  52. 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;
@@ -5,19 +5,18 @@ import { TextDriver } from './channels/TextDriver.js';
5
5
  import { createRunContext } from './ctx.js';
6
6
  import { createEventBus, createTurnHandle } from '../events/TurnHandle.js';
7
7
  import { CoreToolExecutor } from '../tools/effect/index.js';
8
- import { createFsTool } from '../tools/fs/createFsTool.js';
9
- import { wireAgentSkills } from '../skills/wireAgentSkills.js';
8
+ import { buildAgentToolSurface } from './buildAgentToolSurface.js';
10
9
  import { hostLoop } from './hostLoop.js';
11
10
  import { isDegradableRuntimeError } from '../flow/degradableErrors.js';
12
11
  import { SAFE_DEGRADED_MESSAGE } from '../flow/degrade.js';
12
+ import { adaptHostSelect } from './hostClassifyAdapter.js';
13
13
  import { openRun } from './openRun.js';
14
14
  import { closeRun } from './closeRun.js';
15
15
  import { SessionRunStore } from './durable/SessionRunStore.js';
16
16
  import { loadRecordedSteps } from './durable/replay.js';
17
17
  import { markSessionOutcome } from './outcomeMarking.js';
18
18
  import { resolveAgentPolicies } from './policies/resolvePolicies.js';
19
- import { buildAutoRetrieveProvider, buildKnowledgeProvider, buildMemoryService, runMemoryIngest, wireWorkingMemory, } from './grounding/index.js';
20
- import { resolveAgentWorkspace } from './resolveAgentWorkspace.js';
19
+ import { buildAutoRetrieveProvider, buildKnowledgeProvider, buildMemoryService, runMemoryIngest, } from './grounding/index.js';
21
20
  import { SessionMutex } from './SessionMutex.js';
22
21
  export class Runtime {
23
22
  config;
@@ -65,38 +64,16 @@ export class Runtime {
65
64
  sessionStore: this.sessionStore,
66
65
  });
67
66
  const policies = resolveAgentPolicies(opened.agent);
68
- const agentTools = {
69
- ...(this.config.tools ?? {}),
70
- ...(opened.agent.tools ?? {}),
71
- // Global tools (ADR 0001) are model-visible in speaking turns via the
72
- // drivers; register their executors here too so a model call can actually
73
- // run them. Visibility stays gated (not exposed during collect extraction).
74
- ...(opened.agent.globalTools ?? {}),
75
- };
76
- const resolvedWorkspace = resolveAgentWorkspace(opened.agent.workspace);
77
- if (resolvedWorkspace) {
78
- agentTools.workspace = createFsTool({
79
- fs: resolvedWorkspace.fs,
80
- readOnly: resolvedWorkspace.readOnly,
81
- });
82
- }
83
- const wiredWorkingMemory = await wireWorkingMemory(opened.agent, opened.session, this.config.defaultWorkingMemoryStore);
84
- if (wiredWorkingMemory) {
85
- agentTools.memory_block = wiredWorkingMemory.memoryBlockTool;
86
- }
87
- let skillPrompt;
88
- let skillTools = {};
89
- if (opened.agent.skills) {
90
- const wired = await wireAgentSkills(opened.agent);
91
- if (wired) {
92
- skillTools = wired.tools;
93
- Object.assign(agentTools, wired.tools);
94
- skillPrompt = wired.promptSections.map((s) => s.content).join('\n\n');
95
- }
96
- }
97
- const workspaceTool = agentTools.workspace;
67
+ const knowledgeProvider = this.config.knowledge
68
+ ? buildKnowledgeProvider(this.config.knowledge)
69
+ : undefined;
70
+ const openingSurface = await buildAgentToolSurface(opened.agent, opened.session, {
71
+ configTools: this.config.tools,
72
+ knowledgeProvider,
73
+ defaultWorkingMemoryStore: this.config.defaultWorkingMemoryStore,
74
+ });
98
75
  const toolExecutor = new CoreToolExecutor({
99
- tools: agentTools,
76
+ tools: openingSurface.executorTools,
100
77
  enforcer: policies.enforcer,
101
78
  agentId: opened.agent.id,
102
79
  onInterim: (message) => {
@@ -112,9 +89,6 @@ export class Runtime {
112
89
  if (!model) {
113
90
  throw new Error('Runtime requires agent.model or config.defaultModel');
114
91
  }
115
- const knowledgeProvider = this.config.knowledge
116
- ? buildKnowledgeProvider(this.config.knowledge)
117
- : undefined;
118
92
  runCtx = await createRunContext({
119
93
  session: opened.session,
120
94
  runState: freshRunState,
@@ -136,23 +110,15 @@ export class Runtime {
136
110
  memoryService: this.config.memoryService
137
111
  ? buildMemoryService(this.config.memoryService, opened.agent)
138
112
  : undefined,
139
- fs: resolvedWorkspace?.fs,
113
+ fs: openingSurface.resolvedWorkspace?.fs,
140
114
  });
141
115
  // Agent base layer (ADR 0001): composed into every node turn by the drivers.
142
116
  runCtx.baseInstructions = opened.agent.instructions;
143
- runCtx.globalTools = {
144
- ...(opened.agent.globalTools ?? {}),
145
- ...(workspaceTool && resolvedWorkspace?.readOnly !== false
146
- ? { workspace: workspaceTool }
147
- : {}),
148
- ...skillTools,
149
- };
117
+ runCtx.globalTools = openingSurface.globalTools;
150
118
  runCtx.outOfBandControl = opened.agent.experimental?.outOfBandControl ?? false;
151
- runCtx.skillPrompt = skillPrompt;
152
- runCtx.workingMemoryPrompt = wiredWorkingMemory?.promptSection;
153
- runCtx.workingMemoryTools = wiredWorkingMemory
154
- ? { memory_block: wiredWorkingMemory.memoryBlockTool }
155
- : undefined;
119
+ runCtx.skillPrompt = openingSurface.skillPrompt;
120
+ runCtx.workingMemoryPrompt = openingSurface.workingMemoryPrompt;
121
+ runCtx.workingMemoryTools = openingSurface.workingMemoryTools;
156
122
  await this.hooks?.onStart?.(runCtx);
157
123
  const driver = opts.driver ?? new TextDriver();
158
124
  let activeAgent = opened.agent;
@@ -166,7 +132,8 @@ export class Runtime {
166
132
  run: runCtx.runState,
167
133
  driver,
168
134
  ctx: runCtx,
169
- select: this.config.hostSelect,
135
+ classify: this.config.hostClassify ??
136
+ (this.config.hostSelect ? adaptHostSelect(this.config.hostSelect) : undefined),
170
137
  });
171
138
  if (loopResult.kind === 'handoff') {
172
139
  if (this.terminalHandoffTargets.has(loopResult.to)) {
@@ -191,17 +158,22 @@ export class Runtime {
191
158
  });
192
159
  runCtx.runState.activeAgentId = loopResult.to;
193
160
  activeAgent = target;
161
+ const targetSurface = await buildAgentToolSurface(target, opened.session, {
162
+ configTools: this.config.tools,
163
+ knowledgeProvider,
164
+ defaultWorkingMemoryStore: this.config.defaultWorkingMemoryStore,
165
+ });
194
166
  runCtx.autoRetrieve = knowledgeProvider
195
167
  ? buildAutoRetrieveProvider(knowledgeProvider, target)
196
168
  : undefined;
169
+ runCtx.globalTools = targetSurface.globalTools;
170
+ runCtx.skillPrompt = targetSurface.skillPrompt;
171
+ runCtx.workingMemoryPrompt = targetSurface.workingMemoryPrompt;
172
+ runCtx.workingMemoryTools = targetSurface.workingMemoryTools;
173
+ runCtx.fs = targetSurface.resolvedWorkspace?.fs;
197
174
  runCtx.memoryService = this.config.memoryService
198
175
  ? buildMemoryService(this.config.memoryService, target)
199
176
  : undefined;
200
- const targetWorkingMemory = await wireWorkingMemory(target, opened.session, this.config.defaultWorkingMemoryStore);
201
- runCtx.workingMemoryPrompt = targetWorkingMemory?.promptSection;
202
- runCtx.workingMemoryTools = targetWorkingMemory
203
- ? { memory_block: targetWorkingMemory.memoryBlockTool }
204
- : undefined;
205
177
  await runCtx.runStore.putRunState(runCtx.runState);
206
178
  continue;
207
179
  }
@@ -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
  }
@@ -0,0 +1,20 @@
1
+ import type { AgentConfig } from '../types/agentConfig.js';
2
+ import type { Session } from '../types/session.js';
3
+ import type { AnyTool } from '../types/effectTool.js';
4
+ import type { PersistentMemoryStore } from '../memory/blocks/types.js';
5
+ import type { KnowledgeProvider } from './KnowledgeProvider.js';
6
+ import { type ResolvedAgentWorkspace } from './resolveAgentWorkspace.js';
7
+ export interface AgentToolSurface {
8
+ executorTools: Record<string, AnyTool>;
9
+ globalTools: Record<string, AnyTool>;
10
+ workingMemoryTools?: Record<string, AnyTool>;
11
+ workingMemoryPrompt?: string;
12
+ skillPrompt?: string;
13
+ resolvedWorkspace?: ResolvedAgentWorkspace;
14
+ }
15
+ export interface BuildAgentToolSurfaceDeps {
16
+ configTools?: Record<string, AnyTool>;
17
+ knowledgeProvider?: KnowledgeProvider;
18
+ defaultWorkingMemoryStore?: PersistentMemoryStore;
19
+ }
20
+ export declare function buildAgentToolSurface(agent: AgentConfig, session: Session, deps: BuildAgentToolSurfaceDeps): Promise<AgentToolSurface>;
@@ -0,0 +1,56 @@
1
+ import { createFsTool } from '../tools/fs/createFsTool.js';
2
+ import { wireAgentSkills } from '../skills/wireAgentSkills.js';
3
+ import { buildKnowledgeTool, wireWorkingMemory } from './grounding/index.js';
4
+ import { resolveAgentWorkspace, } from './resolveAgentWorkspace.js';
5
+ export async function buildAgentToolSurface(agent, session, deps) {
6
+ const executorTools = {
7
+ ...(deps.configTools ?? {}),
8
+ ...(agent.tools ?? {}),
9
+ ...(agent.globalTools ?? {}),
10
+ };
11
+ const resolvedWorkspace = resolveAgentWorkspace(agent.workspace);
12
+ let workspaceTool;
13
+ if (resolvedWorkspace) {
14
+ workspaceTool = createFsTool({
15
+ fs: resolvedWorkspace.fs,
16
+ readOnly: resolvedWorkspace.readOnly,
17
+ });
18
+ executorTools.workspace = workspaceTool;
19
+ }
20
+ const wiredWorkingMemory = await wireWorkingMemory(agent, session, deps.defaultWorkingMemoryStore);
21
+ if (wiredWorkingMemory) {
22
+ executorTools.memory_block = wiredWorkingMemory.memoryBlockTool;
23
+ }
24
+ let skillPrompt;
25
+ let skillTools = {};
26
+ if (agent.skills) {
27
+ const wired = await wireAgentSkills(agent);
28
+ if (wired) {
29
+ skillTools = wired.tools;
30
+ Object.assign(executorTools, wired.tools);
31
+ skillPrompt = wired.promptSections.map((s) => s.content).join('\n\n');
32
+ }
33
+ }
34
+ const knowledgeTool = deps.knowledgeProvider
35
+ ? buildKnowledgeTool(deps.knowledgeProvider, agent)
36
+ : undefined;
37
+ if (knowledgeTool) {
38
+ executorTools.knowledge_search = knowledgeTool;
39
+ }
40
+ const globalTools = {
41
+ ...(agent.globalTools ?? {}),
42
+ ...(workspaceTool && resolvedWorkspace?.readOnly !== false ? { workspace: workspaceTool } : {}),
43
+ ...skillTools,
44
+ ...(knowledgeTool ? { knowledge_search: knowledgeTool } : {}),
45
+ };
46
+ return {
47
+ executorTools,
48
+ globalTools,
49
+ workingMemoryPrompt: wiredWorkingMemory?.promptSection,
50
+ workingMemoryTools: wiredWorkingMemory
51
+ ? { memory_block: wiredWorkingMemory.memoryBlockTool }
52
+ : undefined,
53
+ skillPrompt,
54
+ resolvedWorkspace,
55
+ };
56
+ }
@@ -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 = {}) {
@@ -81,7 +82,11 @@ export class TextDriver {
81
82
  args: call.input,
82
83
  toolCallId: call.toolCallId,
83
84
  });
84
- const { result: toolResult, control, failed } = await executeModelToolCall(ctx, { toolName: call.toolName, input: call.input, toolCallId: call.toolCallId }, node.localTools);
85
+ const { result: toolResult, control, failed } = await executeModelToolCall(ctx, { toolName: call.toolName, input: call.input, toolCallId: call.toolCallId }, {
86
+ ...ctx.globalTools,
87
+ ...(ctx.workingMemoryTools ?? {}),
88
+ ...node.localTools,
89
+ });
85
90
  out.toolResults.push({
86
91
  name: call.toolName,
87
92
  args: call.input,
@@ -108,24 +113,36 @@ export class TextDriver {
108
113
  }
109
114
  },
110
115
  };
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
- });
116
+ const 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
+ const speakFn = node.hostControl
127
+ ? speakWithHostControl({
128
+ ctx,
129
+ mode,
130
+ turnId,
131
+ source,
132
+ runGate,
133
+ dispatchMode: node.hostControl.dispatchMode,
134
+ getToolControl: () => out.control,
135
+ })
136
+ : (await import('./streaming/speakGated.js')).speakGated({
137
+ ctx,
138
+ mode,
139
+ turnId,
140
+ source,
141
+ runGate,
142
+ });
143
+ const spoken = await speakFn;
127
144
  out.text = spoken.text;
128
- out.control = spoken.control;
145
+ out.control = spoken.control ?? out.control;
129
146
  out.confidence = spoken.confidence;
130
147
  ctx.emit({ type: 'turn-end' });
131
148
  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.
@@ -198,7 +202,11 @@ export class VoiceDriver {
198
202
  const onToolCall = (id, name, args) => {
199
203
  void (async () => {
200
204
  ctx.emit({ type: 'tool-call', toolName: name, args, toolCallId: id });
201
- const { result: toolResult, control, failed } = await executeModelToolCall(ctx, { toolName: name, input: args, toolCallId: id }, localTools);
205
+ const { result: toolResult, control, failed } = await executeModelToolCall(ctx, { toolName: name, input: args, toolCallId: id }, {
206
+ ...ctx.globalTools,
207
+ ...(ctx.workingMemoryTools ?? {}),
208
+ ...localTools,
209
+ });
202
210
  out.toolResults.push({ name, args, result: toolResult, toolCallId: id });
203
211
  toolCallsMade.push({
204
212
  toolCallId: id,
@@ -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
+ }