@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
@@ -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
+ }
@@ -1,4 +1,4 @@
1
- export { appendGatherBlocks, buildAutoRetrieveProvider, buildKnowledgeProvider, } from './knowledge.js';
1
+ export { appendGatherBlocks, buildAutoRetrieveProvider, buildKnowledgeProvider, buildKnowledgeTool, } from './knowledge.js';
2
2
  export { buildMemoryService, resetMissingUserIdWarningsForTests, runMemoryIngest, warnMissingUserId, } from './memory.js';
3
3
  export { wireWorkingMemory, loadWorkingMemoryBlocks, formatWorkingMemorySection, resolveWorkingMemoryStore, resolveWorkingMemoryOwner, type LoadedWorkingMemoryBlock, type WiredWorkingMemory, } from './workingMemory.js';
4
4
  export { runGatherPhase, type GatherResult, type GatherScope } from './gather.js';
@@ -1,4 +1,4 @@
1
- export { appendGatherBlocks, buildAutoRetrieveProvider, buildKnowledgeProvider, } from './knowledge.js';
1
+ export { appendGatherBlocks, buildAutoRetrieveProvider, buildKnowledgeProvider, buildKnowledgeTool, } from './knowledge.js';
2
2
  export { buildMemoryService, resetMissingUserIdWarningsForTests, runMemoryIngest, warnMissingUserId, } from './memory.js';
3
3
  export { wireWorkingMemory, loadWorkingMemoryBlocks, formatWorkingMemorySection, resolveWorkingMemoryStore, resolveWorkingMemoryOwner, } from './workingMemory.js';
4
4
  export { runGatherPhase } from './gather.js';
@@ -1,7 +1,9 @@
1
1
  import type { AgentConfig } from '../../types/agentConfig.js';
2
2
  import type { AutoRetrieveProvider } from '../../types/run-context.js';
3
+ import type { AnyTool } from '../../types/effectTool.js';
3
4
  import type { KnowledgeProviderConfig } from '../../types/voice.js';
4
5
  import { KnowledgeProvider } from '../KnowledgeProvider.js';
5
6
  export declare function buildKnowledgeProvider(config: KnowledgeProviderConfig): KnowledgeProvider;
6
7
  export declare function buildAutoRetrieveProvider(provider: KnowledgeProvider, agent: AgentConfig): AutoRetrieveProvider | undefined;
8
+ export declare function buildKnowledgeTool(provider: KnowledgeProvider, agent: AgentConfig): AnyTool | undefined;
7
9
  export declare function appendGatherBlocks(system: string, blocks: Array<string | undefined>): string;
@@ -1,3 +1,5 @@
1
+ import { z } from 'zod';
2
+ import { defineTool } from '../../tools/effect/defineTool.js';
1
3
  import { normalizeCitations } from '../citations/index.js';
2
4
  import { KnowledgeProvider } from '../KnowledgeProvider.js';
3
5
  function latestUserMessage(ctx) {
@@ -61,6 +63,41 @@ export function buildAutoRetrieveProvider(provider, agent) {
61
63
  },
62
64
  };
63
65
  }
66
+ export function buildKnowledgeTool(provider, agent) {
67
+ if (!agent.knowledge || agent.knowledge.autoRetrieve !== false) {
68
+ return undefined;
69
+ }
70
+ if (!provider.hasRetriever && !provider.hasCompiled) {
71
+ return undefined;
72
+ }
73
+ const overrides = agent.knowledge;
74
+ return defineTool({
75
+ name: 'knowledge_search',
76
+ description: 'Search the knowledge base for facts needed to answer the user. Call this whenever you need grounded information before answering. Returns relevant document snippets.',
77
+ input: z.object({
78
+ query: z
79
+ .string()
80
+ .trim()
81
+ .min(1, 'Query must not be empty.')
82
+ .describe("What to look up, in the user's language."),
83
+ }),
84
+ execute: async ({ query }, ctx) => {
85
+ const { results, events } = await provider.retrieve(query, undefined, overrides, false);
86
+ if (ctx?.emit) {
87
+ for (const event of events) {
88
+ ctx.emit(event);
89
+ }
90
+ }
91
+ const compiled = provider.getCompiledKnowledge(overrides);
92
+ return {
93
+ documents: [
94
+ ...(compiled ? [compiled] : []),
95
+ ...results.map((result) => result.text),
96
+ ],
97
+ };
98
+ },
99
+ });
100
+ }
64
101
  export function appendGatherBlocks(system, blocks) {
65
102
  const extras = blocks.filter((block) => Boolean(block?.trim()));
66
103
  if (extras.length === 0) {
@@ -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;
@@ -0,0 +1,67 @@
1
+ import { classifyHostTarget, } from './select.js';
2
+ import { availableHostFlows, collectTransferTargets } from './hostControlTools.js';
3
+ export function startHostControlGuard(options) {
4
+ const classify = options.classify ?? classifyHostTarget;
5
+ return classify({
6
+ agent: options.agent,
7
+ run: options.run,
8
+ model: options.model,
9
+ allowKeep: true,
10
+ });
11
+ }
12
+ export function isValidControl(control, agent, run) {
13
+ switch (control.type) {
14
+ case 'enterFlow': {
15
+ const flows = availableHostFlows(agent, run);
16
+ return flows.some((f) => f.name === control.flowName);
17
+ }
18
+ case 'handoff':
19
+ return collectTransferTargets(agent).some((t) => t.id === control.target);
20
+ case 'end':
21
+ case 'escalate':
22
+ case 'recover':
23
+ return true;
24
+ default:
25
+ return false;
26
+ }
27
+ }
28
+ export function isValidGuardVerdict(verdict, agent, run) {
29
+ if (verdict.action === 'keep') {
30
+ return true;
31
+ }
32
+ if (verdict.action === 'enterFlow' && verdict.flowName) {
33
+ return availableHostFlows(agent, run).some((f) => f.name === verdict.flowName);
34
+ }
35
+ if (verdict.action === 'transfer' && verdict.targetAgentId) {
36
+ return collectTransferTargets(agent).some((t) => t.id === verdict.targetAgentId);
37
+ }
38
+ return false;
39
+ }
40
+ export function guardVerdictToControl(verdict) {
41
+ if (verdict.action === 'enterFlow' && verdict.flowName) {
42
+ return { type: 'enterFlow', flowName: verdict.flowName, reason: verdict.reason };
43
+ }
44
+ if (verdict.action === 'transfer' && verdict.targetAgentId) {
45
+ return { type: 'handoff', target: verdict.targetAgentId, reason: verdict.reason };
46
+ }
47
+ return undefined;
48
+ }
49
+ /**
50
+ * Main model control wins when valid. The guard is a forgot-to-route net, NOT a
51
+ * second-guesser: it only overrides when the answering model produced neither a
52
+ * control tool NOR a substantive answer (`mainAnswered`). If the model answered,
53
+ * the answer stands — a guard verdict must not hijack a correct keep answer
54
+ * (which caused observed mis-routes of Q&A turns into flows).
55
+ */
56
+ export function resolveHostControl(mainControl, guardVerdict, agent, run, mainAnswered) {
57
+ if (mainControl && isValidControl(mainControl, agent, run)) {
58
+ return mainControl;
59
+ }
60
+ if (!mainAnswered &&
61
+ guardVerdict &&
62
+ guardVerdict.action !== 'keep' &&
63
+ isValidGuardVerdict(guardVerdict, agent, run)) {
64
+ return guardVerdictToControl(guardVerdict);
65
+ }
66
+ return undefined;
67
+ }
@@ -0,0 +1,12 @@
1
+ import type { AgentConfig } from '../types/agentConfig.js';
2
+ import type { RunState } from './durable/types.js';
3
+ import type { AnyTool } from '../types/effectTool.js';
4
+ import type { Flow } from '../types/flow.js';
5
+ export interface TransferTarget {
6
+ id: string;
7
+ descriptions: string[];
8
+ }
9
+ export declare function availableHostFlows(agent: AgentConfig, run: RunState): Flow[];
10
+ export declare function collectTransferTargets(agent: AgentConfig): TransferTarget[];
11
+ export declare function buildHostControlTools(agent: AgentConfig, run: RunState): Record<string, AnyTool>;
12
+ export declare function hasHostControlTargets(agent: AgentConfig, run: RunState): boolean;
@@ -0,0 +1,89 @@
1
+ import { z } from 'zod';
2
+ import { defineTool } from '../tools/effect/defineTool.js';
3
+ import { createEnterFlowTool } from '../tools/enterFlow.js';
4
+ export function availableHostFlows(agent, run) {
5
+ const flows = agent.flows ?? [];
6
+ const completedRaw = run.state.__completedFlows;
7
+ const completed = Array.isArray(completedRaw) ? completedRaw : [];
8
+ return flows.filter((flow) => !completed.includes(flow.name) && flow.name !== run.activeFlow);
9
+ }
10
+ export function collectTransferTargets(agent) {
11
+ const byId = new Map();
12
+ for (const route of agent.routes ?? []) {
13
+ if (!route.agent) {
14
+ continue;
15
+ }
16
+ const desc = `When: ${route.when}`;
17
+ const existing = byId.get(route.agent);
18
+ if (existing) {
19
+ existing.descriptions.push(desc);
20
+ }
21
+ else {
22
+ byId.set(route.agent, { id: route.agent, descriptions: [desc] });
23
+ }
24
+ }
25
+ for (const child of agent.agents ?? []) {
26
+ const desc = child.description ?? child.name ?? child.id;
27
+ const existing = byId.get(child.id);
28
+ if (existing) {
29
+ if (!existing.descriptions.includes(desc)) {
30
+ existing.descriptions.push(desc);
31
+ }
32
+ }
33
+ else {
34
+ byId.set(child.id, { id: child.id, descriptions: [desc] });
35
+ }
36
+ }
37
+ for (const handoffId of agent.handoffs ?? []) {
38
+ const child = agent.agents?.find((a) => a.id === handoffId);
39
+ const desc = child?.description ?? child?.name ?? handoffId;
40
+ const existing = byId.get(handoffId);
41
+ if (existing) {
42
+ if (!existing.descriptions.includes(desc)) {
43
+ existing.descriptions.push(desc);
44
+ }
45
+ }
46
+ else {
47
+ byId.set(handoffId, { id: handoffId, descriptions: [desc] });
48
+ }
49
+ }
50
+ return [...byId.values()];
51
+ }
52
+ function createTransferToAgentTool(targets) {
53
+ const ids = targets.map((t) => t.id);
54
+ const lines = targets.map((t) => `- ${t.id}: ${t.descriptions.join('; ')}`).join('\n');
55
+ return defineTool({
56
+ name: 'transfer_to_agent',
57
+ description: 'Transfer the conversation to a specialized agent when the user needs a specialist. ' +
58
+ 'Call this INSTEAD of answering in prose or saying a filler/acknowledgement like "Sure" or "One moment"; ' +
59
+ 'do not announce the transfer to the user.\n\n' +
60
+ `Available targets:\n${lines}`,
61
+ input: z.object({
62
+ targetAgentId: z.enum(ids).describe('Target agent id'),
63
+ reason: z.string().describe('Why this transfer — include relevant context'),
64
+ summary: z.string().optional().describe('Optional summary of progress so far'),
65
+ }),
66
+ execute: async ({ targetAgentId, reason, summary }) => ({
67
+ __handoff: true,
68
+ targetAgentId,
69
+ targetAgent: targetAgentId,
70
+ reason,
71
+ summary,
72
+ }),
73
+ });
74
+ }
75
+ export function buildHostControlTools(agent, run) {
76
+ const tools = {};
77
+ const flows = availableHostFlows(agent, run);
78
+ if (flows.length > 0) {
79
+ tools.enter_flow = createEnterFlowTool(flows);
80
+ }
81
+ const targets = collectTransferTargets(agent);
82
+ if (targets.length > 0) {
83
+ tools.transfer_to_agent = createTransferToAgentTool(targets);
84
+ }
85
+ return tools;
86
+ }
87
+ export function hasHostControlTargets(agent, run) {
88
+ return (availableHostFlows(agent, run).length > 0 || collectTransferTargets(agent).length > 0);
89
+ }
@@ -2,7 +2,8 @@ import type { AgentConfig } from '../types/agentConfig.js';
2
2
  import type { ChannelDriver } from '../types/channel.js';
3
3
  import type { RunContext } from '../types/run-context.js';
4
4
  import type { RunState } from './durable/types.js';
5
- import { selectHostTarget } from './select.js';
5
+ import { type ClassifyHostOptions, type HostGuardVerdict } from './select.js';
6
+ import type { selectHostTarget } from './select.js';
6
7
  export type HostLoopResult = {
7
8
  kind: 'handoff';
8
9
  to: string;
@@ -20,6 +21,8 @@ export interface HostLoopOptions {
20
21
  run: RunState;
21
22
  driver: ChannelDriver;
22
23
  ctx: RunContext;
24
+ classify?: (opts: ClassifyHostOptions) => Promise<HostGuardVerdict>;
25
+ /** @deprecated Test injection — use classify. */
23
26
  select?: typeof selectHostTarget;
24
27
  }
25
28
  export declare function hostLoop(options: HostLoopOptions): Promise<HostLoopResult>;
@@ -2,12 +2,17 @@ import { runFlow } from '../flow/runFlow.js';
2
2
  import { resolveReplyNode } from '../flow/nodeBuilders.js';
3
3
  import { SuspendError } from './durable/RunStore.js';
4
4
  import { buildAgentReplyNode } from './agentReply.js';
5
- import { deriveAgentCapabilities, shouldRunHostSelector } from './deriveAgent.js';
6
- import { selectHostTarget } from './select.js';
5
+ import { deriveAgentShape } from './deriveAgent.js';
7
6
  import { assertWithinTurnLimit, incrementTurnCount, LimitsExceededError, } from './policies/limits.js';
7
+ import { classifyHostTarget, verdictToSelection, } from './select.js';
8
+ import { hasHostControlTargets } from './hostControlTools.js';
9
+ import { isValidControl, resolveHostControl, startHostControlGuard, } from './hostControlGuard.js';
10
+ import { resolveDispatchMode, isAdvisoryDispatch } from './dispatchMode.js';
11
+ import { adaptHostSelect } from './hostClassifyAdapter.js';
8
12
  export async function hostLoop(options) {
9
13
  const { agent, run, driver, ctx } = options;
10
- const select = options.select ?? selectHostTarget;
14
+ const classify = options.classify ??
15
+ (options.select ? adaptHostSelect(options.select) : classifyHostTarget);
11
16
  try {
12
17
  if (run.activeFlow) {
13
18
  const flow = findFlowByName(agent, run.activeFlow);
@@ -16,23 +21,14 @@ export async function hostLoop(options) {
16
21
  }
17
22
  return await runActiveFlow(flow, run, driver, ctx, agent);
18
23
  }
19
- const alwaysRoute = agent.routing?.always === true;
20
- if (shouldRunHostSelector(agent, run.activeFlow, alwaysRoute)) {
21
- const selection = await select({
22
- agent,
23
- run,
24
- model: agent.routing?.model ?? ctx.controlModel,
25
- alwaysRoute,
26
- });
27
- if (selection.kind === 'enterFlow') {
28
- return await runActiveFlow(selection.flow, run, driver, ctx, agent);
29
- }
30
- if (selection.kind === 'route') {
31
- ctx.emit({ type: 'handoff', targetAgent: selection.agentId, reason: selection.reason });
32
- return { kind: 'handoff', to: selection.agentId, reason: selection.reason };
33
- }
24
+ const shape = deriveAgentShape(agent);
25
+ if (shape.isPureDispatcher) {
26
+ return await runPureDispatcher(agent, run, driver, ctx, classify);
27
+ }
28
+ if (shape.isAnsweringAgent) {
29
+ return await runAnsweringAgent(agent, run, driver, ctx, classify);
34
30
  }
35
- return await runFreeConversation(agent, run, driver, ctx);
31
+ return await runFreeConversation(agent, run, driver, ctx, classify);
36
32
  }
37
33
  catch (error) {
38
34
  if (error instanceof SuspendError) {
@@ -45,6 +41,21 @@ export async function hostLoop(options) {
45
41
  throw error;
46
42
  }
47
43
  }
44
+ async function runPureDispatcher(agent, run, driver, ctx, classify) {
45
+ incrementTurnCount(run);
46
+ assertWithinTurnLimit(run, ctx.limits);
47
+ const model = agent.routing?.model ?? ctx.controlModel;
48
+ const verdict = await classify({
49
+ agent,
50
+ run,
51
+ model,
52
+ allowKeep: false,
53
+ });
54
+ return await executeHostControl(agent, run, driver, ctx, guardVerdictToControl(verdict, agent));
55
+ }
56
+ async function runAnsweringAgent(agent, run, driver, ctx, classify) {
57
+ return await runFreeConversation(agent, run, driver, ctx, classify);
58
+ }
48
59
  async function runActiveFlow(flow, run, driver, ctx, agent) {
49
60
  incrementTurnCount(run);
50
61
  assertWithinTurnLimit(run, ctx.limits);
@@ -66,33 +77,110 @@ async function runActiveFlow(flow, run, driver, ctx, agent) {
66
77
  await ctx.runStore.putRunState(run);
67
78
  return { kind: 'turnComplete' };
68
79
  }
69
- async function runFreeConversation(agent, run, driver, ctx) {
70
- const caps = deriveAgentCapabilities(agent);
71
- if (!caps.hasFreeConversation && !agent.tools) {
80
+ async function runFreeConversation(agent, run, driver, ctx, classify) {
81
+ const shape = deriveAgentShape(agent);
82
+ if (!shape.isAnsweringAgent) {
72
83
  return { kind: 'turnComplete' };
73
84
  }
74
85
  incrementTurnCount(run);
75
86
  assertWithinTurnLimit(run, ctx.limits);
76
- const replyNode = buildAgentReplyNode(agent);
77
- const turn = await driver.runAgentTurn(resolveReplyNode(replyNode, run.state, { freeConversation: true }), ctx);
87
+ const capability = driver.outputCapability ?? 'kuralle-controlled-text';
88
+ const dispatchMode = resolveDispatchMode(agent, capability);
89
+ const advisoryDispatch = isAdvisoryDispatch(capability);
90
+ const needsGuard = hasHostControlTargets(agent, run);
91
+ const controlModel = agent.routing?.model ?? ctx.controlModel;
92
+ const startGuard = needsGuard
93
+ ? () => startHostControlGuard({
94
+ agent,
95
+ run,
96
+ model: controlModel,
97
+ classify,
98
+ })
99
+ : undefined;
100
+ const replyNode = buildAgentReplyNode(agent, run);
101
+ const resolved = resolveReplyNode(replyNode, run.state, { freeConversation: true });
102
+ if (needsGuard) {
103
+ // The driver only buffers/streams per dispatch mode; the guard has a single
104
+ // owner (this loop, on the empty-turn branch below) so it runs at most once.
105
+ resolved.hostControl = { dispatchMode, advisoryDispatch };
106
+ }
107
+ const turn = await driver.runAgentTurn(resolved, ctx);
108
+ if (turn.control && isValidControl(turn.control, agent, run)) {
109
+ emitHostGuardTelemetry(ctx, { invoked: false, reason: 'main-control' });
110
+ return await executeHostControl(agent, run, driver, ctx, turn.control);
111
+ }
78
112
  if (turn.text.trim()) {
113
+ emitHostGuardTelemetry(ctx, { invoked: false, reason: 'answered' });
79
114
  const message = { role: 'assistant', content: turn.text };
80
115
  run.messages = [...run.messages, message];
81
116
  await ctx.runStore.putRunState(run);
117
+ return { kind: 'turnComplete' };
118
+ }
119
+ if (startGuard) {
120
+ const guardVerdict = await startGuard();
121
+ const control = resolveHostControl(undefined, guardVerdict, agent, run, false);
122
+ if (control) {
123
+ emitHostGuardTelemetry(ctx, {
124
+ invoked: true,
125
+ reason: 'empty-routed',
126
+ verdict: guardVerdictToTelemetryVerdict(guardVerdict),
127
+ });
128
+ return await executeHostControl(agent, run, driver, ctx, control);
129
+ }
130
+ emitHostGuardTelemetry(ctx, {
131
+ invoked: true,
132
+ reason: 'empty-kept',
133
+ verdict: guardVerdictToTelemetryVerdict(guardVerdict),
134
+ });
135
+ }
136
+ return { kind: 'turnComplete' };
137
+ }
138
+ function guardVerdictToTelemetryVerdict(verdict) {
139
+ if (verdict.action === 'enterFlow')
140
+ return 'enterFlow';
141
+ if (verdict.action === 'transfer')
142
+ return 'transfer';
143
+ return 'keep';
144
+ }
145
+ function emitHostGuardTelemetry(ctx, data) {
146
+ ctx.emit({ type: 'custom', name: 'host-guard', data });
147
+ }
148
+ function guardVerdictToControl(verdict, agent) {
149
+ const selection = verdictToSelection(verdict, agent);
150
+ if (!selection || selection.kind === 'keep') {
151
+ return undefined;
152
+ }
153
+ if (selection.kind === 'enterFlow') {
154
+ return { type: 'enterFlow', flowName: selection.flow.name };
155
+ }
156
+ return { type: 'handoff', target: selection.agentId, reason: selection.reason };
157
+ }
158
+ async function executeHostControl(agent, run, driver, ctx, control) {
159
+ if (!control) {
160
+ ctx.emit({ type: 'error', error: 'No valid host control target resolved' });
161
+ return { kind: 'ended', reason: 'dispatch_failed' };
162
+ }
163
+ if (control.type === 'enterFlow') {
164
+ const flow = findFlowByName(agent, control.flowName);
165
+ if (flow) {
166
+ return await runActiveFlow(flow, run, driver, ctx, agent);
167
+ }
168
+ ctx.emit({ type: 'error', error: `Flow not found: ${control.flowName}` });
169
+ return { kind: 'ended', reason: 'flow_not_found' };
82
170
  }
83
- if (turn.control?.type === 'handoff') {
84
- ctx.emit({ type: 'handoff', targetAgent: turn.control.target, reason: turn.control.reason });
85
- return { kind: 'handoff', to: turn.control.target, reason: turn.control.reason };
171
+ if (control.type === 'handoff') {
172
+ ctx.emit({ type: 'handoff', targetAgent: control.target, reason: control.reason });
173
+ return { kind: 'handoff', to: control.target, reason: control.reason };
86
174
  }
87
- if (turn.control?.type === 'end') {
88
- return { kind: 'ended', reason: turn.control.reason };
175
+ if (control.type === 'end') {
176
+ return { kind: 'ended', reason: control.reason };
89
177
  }
90
- if (turn.control?.type === 'escalate') {
91
- ctx.emit({ type: 'handoff', targetAgent: 'human', reason: turn.control.reason });
92
- return { kind: 'handoff', to: 'human', reason: turn.control.reason };
178
+ if (control.type === 'escalate') {
179
+ ctx.emit({ type: 'handoff', targetAgent: 'human', reason: control.reason });
180
+ return { kind: 'handoff', to: 'human', reason: control.reason };
93
181
  }
94
- if (turn.control?.type === 'recover') {
95
- return { kind: 'ended', reason: turn.control.reason ?? 'error_degraded' };
182
+ if (control.type === 'recover') {
183
+ return { kind: 'ended', reason: control.reason ?? 'error_degraded' };
96
184
  }
97
185
  return { kind: 'turnComplete' };
98
186
  }