@kuralle-agents/core 0.3.7 → 0.3.8

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.
@@ -1,5 +1,6 @@
1
1
  import { isHandoffResult } from '../tools/handoff.js';
2
2
  import { isFinalResult } from '../tools/final.js';
3
+ import { isEscalateResult, isRecoverResult } from '../tools/controlResults.js';
3
4
  export function classifyControl(result) {
4
5
  if (isHandoffResult(result)) {
5
6
  return {
@@ -11,5 +12,11 @@ export function classifyControl(result) {
11
12
  if (isFinalResult(result)) {
12
13
  return { type: 'end', reason: result.text };
13
14
  }
15
+ if (isEscalateResult(result)) {
16
+ return { type: 'escalate', reason: result.reason };
17
+ }
18
+ if (isRecoverResult(result)) {
19
+ return { type: 'recover', reason: result.reason };
20
+ }
14
21
  return undefined;
15
22
  }
@@ -0,0 +1 @@
1
+ export declare function isDegradableRuntimeError(error: unknown): boolean;
@@ -0,0 +1,5 @@
1
+ import { ToolValidationError } from '../tools/effect/schema.js';
2
+ import { FlowOscillationError } from './runFlow.js';
3
+ export function isDegradableRuntimeError(error) {
4
+ return error instanceof ToolValidationError || error instanceof FlowOscillationError;
5
+ }
@@ -0,0 +1,17 @@
1
+ import type { ChannelDriver } from '../types/channel.js';
2
+ import type { Flow, FlowNode } from '../types/flow.js';
3
+ import type { RunContext } from '../types/run-context.js';
4
+ import type { RunState } from '../runtime/durable/types.js';
5
+ type DegradedFlowResult = {
6
+ kind: 'ended';
7
+ reason: string;
8
+ } | {
9
+ kind: 'handoff';
10
+ to: string;
11
+ reason?: string;
12
+ };
13
+ export declare const SAFE_DEGRADED_MESSAGE = "I'm sorry \u2014 something went wrong on my side. Let me try to help you another way.";
14
+ export declare function findEscalateNode(registry: Map<string, FlowNode>): FlowNode | undefined;
15
+ export declare function appendSafeAssistantMessage(run: RunState, ctx: RunContext, text?: string): void;
16
+ export declare function degradeFlowError(flow: Flow, registry: Map<string, FlowNode>, run: RunState, driver: ChannelDriver, ctx: RunContext, dispatchNode: (node: FlowNode, run: RunState, driver: ChannelDriver, ctx: RunContext) => Promise<import('./normalizeTransition.js').NormalizedTransition>): Promise<DegradedFlowResult>;
17
+ export {};
@@ -0,0 +1,31 @@
1
+ import { SuspendError } from '../runtime/durable/RunStore.js';
2
+ export const SAFE_DEGRADED_MESSAGE = "I'm sorry — something went wrong on my side. Let me try to help you another way.";
3
+ export function findEscalateNode(registry) {
4
+ return registry.get('escalate');
5
+ }
6
+ export function appendSafeAssistantMessage(run, ctx, text = SAFE_DEGRADED_MESSAGE) {
7
+ const message = { role: 'assistant', content: text };
8
+ run.messages = [...run.messages, message];
9
+ ctx.emit({ type: 'text-delta', text });
10
+ }
11
+ export async function degradeFlowError(flow, registry, run, driver, ctx, dispatchNode) {
12
+ appendSafeAssistantMessage(run, ctx);
13
+ const escalateNode = findEscalateNode(registry);
14
+ if (escalateNode) {
15
+ try {
16
+ const transition = await dispatchNode(escalateNode, run, driver, ctx);
17
+ if (transition.kind === 'escalate') {
18
+ await ctx.signal('__escalate', { meta: { reason: transition.reason } });
19
+ return { kind: 'handoff', to: 'human', reason: transition.reason };
20
+ }
21
+ }
22
+ catch (error) {
23
+ if (error instanceof SuspendError) {
24
+ throw error;
25
+ }
26
+ // Fall through to graceful end if the escalate node also fails.
27
+ }
28
+ }
29
+ ctx.emit({ type: 'flow-end', flow: flow.name, reason: 'error_degraded' });
30
+ return { kind: 'ended', reason: 'error_degraded' };
31
+ }
@@ -6,7 +6,10 @@ import { reduceTransition } from './reduceTransition.js';
6
6
  import { resolveReplyNode } from './nodeBuilders.js';
7
7
  import { runNodeVerify, VerifyBlockedError } from './verify.js';
8
8
  import { loadRecordedSteps } from '../runtime/durable/replay.js';
9
+ import { SuspendError } from '../runtime/durable/RunStore.js';
10
+ import { ToolApprovalDeniedError } from '../tools/effect/errors.js';
9
11
  import { emitInteractiveOnNodeEnter } from './emitInteractive.js';
12
+ import { appendSafeAssistantMessage, degradeFlowError, findEscalateNode, } from './degrade.js';
10
13
  export class FlowOscillationError extends Error {
11
14
  constructor(from, to) {
12
15
  super(`Flow oscillation blocked: ${from} -> ${to}`);
@@ -98,6 +101,12 @@ async function dispatchNode(node, run, driver, ctx) {
98
101
  if (turn.control?.type === 'end') {
99
102
  return { kind: 'end', reason: turn.control.reason };
100
103
  }
104
+ if (turn.control?.type === 'escalate') {
105
+ return { kind: 'escalate', reason: turn.control.reason };
106
+ }
107
+ if (turn.control?.type === 'recover') {
108
+ return { kind: 'end', reason: turn.control.reason ?? 'error_degraded' };
109
+ }
101
110
  if (node.next) {
102
111
  return normalizeTransition(await node.next(turn, run.state));
103
112
  }
@@ -123,7 +132,18 @@ export async function runFlow(flow, run, driver, ctx) {
123
132
  const edgeCounts = new Map();
124
133
  const maxOscillations = flow.maxOscillations ?? 2;
125
134
  for (;;) {
126
- const transition = await dispatchNode(node, run, driver, ctx);
135
+ let transition;
136
+ try {
137
+ transition = await dispatchNode(node, run, driver, ctx);
138
+ }
139
+ catch (error) {
140
+ if (error instanceof SuspendError || error instanceof ToolApprovalDeniedError) {
141
+ throw error;
142
+ }
143
+ const message = error instanceof Error ? error.message : String(error);
144
+ ctx.emit({ type: 'error', error: message });
145
+ return degradeFlowError(flow, registry, run, driver, ctx, dispatchNode);
146
+ }
127
147
  if (transition.kind === 'end') {
128
148
  ctx.emit({ type: 'flow-end', flow: flow.name, reason: transition.reason });
129
149
  return { kind: 'ended', reason: transition.reason };
@@ -168,7 +188,26 @@ export async function runFlow(flow, run, driver, ctx) {
168
188
  const oscillation = bumpOscillation(edgeCounts, node.id, target.id);
169
189
  if (oscillation > maxOscillations) {
170
190
  ctx.emit({ type: 'error', error: `Flow oscillation blocked: ${node.id} -> ${target.id}` });
171
- throw new FlowOscillationError(node.id, target.id);
191
+ const escalateNode = findEscalateNode(registry);
192
+ if (escalateNode) {
193
+ appendSafeAssistantMessage(run, ctx);
194
+ await reduceTransition({
195
+ fromNodeId: node.id,
196
+ toNode: escalateNode,
197
+ run,
198
+ flow,
199
+ model: ctx.model,
200
+ data: transition.data,
201
+ emit: ctx.emit,
202
+ abortSignal: ctx.abortSignal,
203
+ });
204
+ await ctx.runStore.putRunState(run);
205
+ node = escalateNode;
206
+ continue;
207
+ }
208
+ appendSafeAssistantMessage(run, ctx);
209
+ ctx.emit({ type: 'flow-end', flow: flow.name, reason: 'error_degraded' });
210
+ return { kind: 'ended', reason: 'error_degraded' };
172
211
  }
173
212
  await reduceTransition({
174
213
  fromNodeId: node.id,
@@ -6,6 +6,8 @@ import { createRunContext } from './ctx.js';
6
6
  import { createEventBus, createTurnHandle } from '../events/TurnHandle.js';
7
7
  import { CoreToolExecutor } from '../tools/effect/index.js';
8
8
  import { hostLoop } from './hostLoop.js';
9
+ import { isDegradableRuntimeError } from '../flow/degradableErrors.js';
10
+ import { SAFE_DEGRADED_MESSAGE } from '../flow/degrade.js';
9
11
  import { openRun } from './openRun.js';
10
12
  import { closeRun } from './closeRun.js';
11
13
  import { SessionRunStore } from './durable/SessionRunStore.js';
@@ -163,7 +165,21 @@ export class Runtime {
163
165
  }
164
166
  catch (error) {
165
167
  await this.hooks?.onError?.(runCtx, error);
166
- throw error;
168
+ if (isDegradableRuntimeError(error)) {
169
+ const message = error instanceof Error ? error.message : String(error);
170
+ emit({ type: 'error', error: message });
171
+ emit({ type: 'text-delta', text: SAFE_DEGRADED_MESSAGE });
172
+ runCtx.runState.messages = [
173
+ ...runCtx.runState.messages,
174
+ { role: 'assistant', content: SAFE_DEGRADED_MESSAGE },
175
+ ];
176
+ await runCtx.runStore.putRunState(runCtx.runState);
177
+ terminalOutcome = 'unresolved';
178
+ loopResult = { kind: 'ended', reason: 'error_degraded' };
179
+ }
180
+ else {
181
+ throw error;
182
+ }
167
183
  }
168
184
  finally {
169
185
  this.activeTurnAborts.delete(sessionId);
@@ -1,7 +1,7 @@
1
1
  import { streamText, generateObject } from 'ai';
2
2
  import { buildNodePrompt, resolveInstructions, composeSystem } from '../../flow/nodeBuilders.js';
3
3
  import { buildToolSet } from '../../tools/effect/index.js';
4
- import { classifyControl } from '../../flow/classifyControl.js';
4
+ import { executeModelToolCall, toolResultMessage } from './executeModelTool.js';
5
5
  import { consumePendingUserInput } from './inputBuffer.js';
6
6
  import { runSilentExtraction } from './extractionTurn.js';
7
7
  import { applyPreTurnPolicies, applyPostTurnPolicies } from '../policies/agentTurn.js';
@@ -70,21 +70,7 @@ export class TextDriver {
70
70
  args: call.input,
71
71
  toolCallId: call.toolCallId,
72
72
  });
73
- const localTool = node.localTools?.[call.toolName];
74
- const toolResult = await ctx.tool(call.toolName, call.input, {
75
- toolCallId: call.toolCallId,
76
- ...(localTool && {
77
- def: localTool,
78
- toolCtx: {
79
- session: ctx.session,
80
- runState: ctx.runState,
81
- tool: ctx.tool.bind(ctx),
82
- now: ctx.now.bind(ctx),
83
- uuid: ctx.uuid.bind(ctx),
84
- emit: ctx.emit.bind(ctx),
85
- },
86
- }),
87
- });
73
+ const { result: toolResult, control, failed } = await executeModelToolCall(ctx, { toolName: call.toolName, input: call.input, toolCallId: call.toolCallId }, node.localTools);
88
74
  out.toolResults.push({
89
75
  name: call.toolName,
90
76
  args: call.input,
@@ -96,27 +82,17 @@ export class TextDriver {
96
82
  toolName: call.toolName,
97
83
  args: call.input,
98
84
  result: toolResult,
99
- success: true,
85
+ success: !failed,
100
86
  timestamp: Date.now(),
101
87
  });
102
- out.control ??= classifyControl(toolResult);
88
+ out.control ??= control;
103
89
  ctx.emit({
104
90
  type: 'tool-result',
105
91
  toolName: call.toolName,
106
92
  result: toolResult,
107
93
  toolCallId: call.toolCallId,
108
94
  });
109
- messages.push({
110
- role: 'tool',
111
- content: [
112
- {
113
- type: 'tool-result',
114
- toolCallId: call.toolCallId,
115
- toolName: call.toolName,
116
- output: { type: 'json', value: toolResult },
117
- },
118
- ],
119
- });
95
+ messages.push(toolResultMessage({ toolName: call.toolName, input: call.input, toolCallId: call.toolCallId }, toolResult));
120
96
  }
121
97
  }
122
98
  const postTurn = await applyPostTurnPolicies(ctx, draftText, toolCallsMade);
@@ -1,7 +1,7 @@
1
1
  import { generateObject } from 'ai';
2
2
  import { runSilentExtraction } from './extractionTurn.js';
3
3
  import { buildNodePrompt, resolveInstructions, composeSystem } from '../../flow/nodeBuilders.js';
4
- import { classifyControl } from '../../flow/classifyControl.js';
4
+ import { executeModelToolCall } from './executeModelTool.js';
5
5
  import { applyPreTurnPolicies, applyPostTurnPolicies } from '../policies/agentTurn.js';
6
6
  import { resolveMaxSteps } from '../policies/limits.js';
7
7
  import { appendGatherBlocks, runGatherPhase } from '../grounding/index.js';
@@ -128,39 +128,20 @@ export class VoiceDriver {
128
128
  };
129
129
  const onToolCall = (id, name, args) => {
130
130
  void (async () => {
131
- try {
132
- ctx.emit({ type: 'tool-call', toolName: name, args, toolCallId: id });
133
- const localTool = localTools?.[name];
134
- const toolResult = await ctx.tool(name, args, {
135
- toolCallId: id,
136
- ...(localTool && {
137
- def: localTool,
138
- toolCtx: {
139
- session: ctx.session,
140
- runState: ctx.runState,
141
- tool: ctx.tool.bind(ctx),
142
- now: ctx.now.bind(ctx),
143
- uuid: ctx.uuid.bind(ctx),
144
- emit: ctx.emit.bind(ctx),
145
- },
146
- }),
147
- });
148
- out.toolResults.push({ name, args, result: toolResult, toolCallId: id });
149
- toolCallsMade.push({
150
- toolCallId: id,
151
- toolName: name,
152
- args,
153
- result: toolResult,
154
- success: true,
155
- timestamp: Date.now(),
156
- });
157
- out.control ??= classifyControl(toolResult);
158
- ctx.emit({ type: 'tool-result', toolName: name, result: toolResult, toolCallId: id });
159
- this.client.sendToolResponse([{ id, name, output: toolResult }]);
160
- }
161
- catch (error) {
162
- fail(error);
163
- }
131
+ ctx.emit({ type: 'tool-call', toolName: name, args, toolCallId: id });
132
+ const { result: toolResult, control, failed } = await executeModelToolCall(ctx, { toolName: name, input: args, toolCallId: id }, localTools);
133
+ out.toolResults.push({ name, args, result: toolResult, toolCallId: id });
134
+ toolCallsMade.push({
135
+ toolCallId: id,
136
+ toolName: name,
137
+ args,
138
+ result: toolResult,
139
+ success: !failed,
140
+ timestamp: Date.now(),
141
+ });
142
+ out.control ??= control;
143
+ ctx.emit({ type: 'tool-result', toolName: name, result: toolResult, toolCallId: id });
144
+ this.client.sendToolResponse([{ id, name, output: toolResult }]);
164
145
  })();
165
146
  };
166
147
  const onTurnComplete = () => {
@@ -0,0 +1,29 @@
1
+ import type { JSONValue } from 'ai';
2
+ import type { TurnControl } from '../../types/channel.js';
3
+ import type { RunContext } from '../../types/run-context.js';
4
+ import type { AnyTool } from '../../types/effectTool.js';
5
+ export interface ModelToolCall {
6
+ toolName: string;
7
+ input: unknown;
8
+ toolCallId: string;
9
+ }
10
+ export interface ModelToolCallOutcome {
11
+ result: unknown;
12
+ control?: TurnControl;
13
+ failed: boolean;
14
+ }
15
+ export declare function executeModelToolCall(ctx: RunContext, call: ModelToolCall, localTools?: Record<string, AnyTool>): Promise<ModelToolCallOutcome>;
16
+ export declare function toolResultMessage(call: ModelToolCall, result: unknown): {
17
+ role: 'tool';
18
+ content: [
19
+ {
20
+ type: 'tool-result';
21
+ toolCallId: string;
22
+ toolName: string;
23
+ output: {
24
+ type: 'json';
25
+ value: JSONValue;
26
+ };
27
+ }
28
+ ];
29
+ };
@@ -0,0 +1,40 @@
1
+ import { classifyControl } from '../../flow/classifyControl.js';
2
+ import { toolErrorResult } from '../../tools/controlResults.js';
3
+ export async function executeModelToolCall(ctx, call, localTools) {
4
+ try {
5
+ const localTool = localTools?.[call.toolName];
6
+ const toolResult = await ctx.tool(call.toolName, call.input, {
7
+ toolCallId: call.toolCallId,
8
+ ...(localTool && {
9
+ def: localTool,
10
+ toolCtx: {
11
+ session: ctx.session,
12
+ runState: ctx.runState,
13
+ tool: ctx.tool.bind(ctx),
14
+ now: ctx.now.bind(ctx),
15
+ uuid: ctx.uuid.bind(ctx),
16
+ emit: ctx.emit.bind(ctx),
17
+ },
18
+ }),
19
+ });
20
+ return { result: toolResult, control: classifyControl(toolResult), failed: false };
21
+ }
22
+ catch (error) {
23
+ const message = error instanceof Error ? error.message : String(error);
24
+ ctx.emit({ type: 'error', error: message });
25
+ return { result: toolErrorResult(error), failed: true };
26
+ }
27
+ }
28
+ export function toolResultMessage(call, result) {
29
+ return {
30
+ role: 'tool',
31
+ content: [
32
+ {
33
+ type: 'tool-result',
34
+ toolCallId: call.toolCallId,
35
+ toolName: call.toolName,
36
+ output: { type: 'json', value: result },
37
+ },
38
+ ],
39
+ };
40
+ }
@@ -1,4 +1,5 @@
1
1
  import { streamText } from 'ai';
2
+ import { executeModelToolCall, toolResultMessage } from './executeModelTool.js';
2
3
  import { buildToolSet } from '../../tools/effect/index.js';
3
4
  import { buildNodePrompt, composeSystem } from '../../flow/nodeBuilders.js';
4
5
  /**
@@ -37,38 +38,14 @@ export async function runSilentExtraction(node, ctx, model, maxSteps) {
37
38
  }
38
39
  const toolCalls = await result.toolCalls;
39
40
  for (const call of toolCalls) {
40
- const localTool = node.localTools?.[call.toolName];
41
- const toolResult = await ctx.tool(call.toolName, call.input, {
42
- toolCallId: call.toolCallId,
43
- ...(localTool && {
44
- def: localTool,
45
- toolCtx: {
46
- session: ctx.session,
47
- runState: ctx.runState,
48
- tool: ctx.tool.bind(ctx),
49
- now: ctx.now.bind(ctx),
50
- uuid: ctx.uuid.bind(ctx),
51
- emit: ctx.emit.bind(ctx),
52
- },
53
- }),
54
- });
41
+ const { result: toolResult } = await executeModelToolCall(ctx, { toolName: call.toolName, input: call.input, toolCallId: call.toolCallId }, node.localTools);
55
42
  out.toolResults.push({
56
43
  name: call.toolName,
57
44
  args: call.input,
58
45
  result: toolResult,
59
46
  toolCallId: call.toolCallId,
60
47
  });
61
- messages.push({
62
- role: 'tool',
63
- content: [
64
- {
65
- type: 'tool-result',
66
- toolCallId: call.toolCallId,
67
- toolName: call.toolName,
68
- output: { type: 'json', value: toolResult },
69
- },
70
- ],
71
- });
48
+ messages.push(toolResultMessage({ toolName: call.toolName, input: call.input, toolCallId: call.toolCallId }, toolResult));
72
49
  }
73
50
  }
74
51
  return out;
@@ -87,6 +87,13 @@ async function runFreeConversation(agent, run, driver, ctx) {
87
87
  if (turn.control?.type === 'end') {
88
88
  return { kind: 'ended', reason: turn.control.reason };
89
89
  }
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 };
93
+ }
94
+ if (turn.control?.type === 'recover') {
95
+ return { kind: 'ended', reason: turn.control.reason ?? 'error_degraded' };
96
+ }
90
97
  return { kind: 'turnComplete' };
91
98
  }
92
99
  function findFlowByName(agent, flowName) {
@@ -0,0 +1,15 @@
1
+ export interface EscalateResult {
2
+ __escalate: true;
3
+ reason: string;
4
+ }
5
+ export declare function isEscalateResult(result: unknown): result is EscalateResult;
6
+ export interface RecoverResult {
7
+ __recover: true;
8
+ reason?: string;
9
+ }
10
+ export declare function isRecoverResult(result: unknown): result is RecoverResult;
11
+ /** Standard tool-result shape when `ctx.tool` fails in a model-initiated call. */
12
+ export declare function toolErrorResult(error: unknown): {
13
+ error: true;
14
+ message: string;
15
+ };
@@ -0,0 +1,18 @@
1
+ export function isEscalateResult(result) {
2
+ return (typeof result === 'object' &&
3
+ result !== null &&
4
+ '__escalate' in result &&
5
+ result.__escalate === true &&
6
+ typeof result.reason === 'string');
7
+ }
8
+ export function isRecoverResult(result) {
9
+ return (typeof result === 'object' &&
10
+ result !== null &&
11
+ '__recover' in result &&
12
+ result.__recover === true);
13
+ }
14
+ /** Standard tool-result shape when `ctx.tool` fails in a model-initiated call. */
15
+ export function toolErrorResult(error) {
16
+ const message = error instanceof Error ? error.message : String(error);
17
+ return { error: true, message };
18
+ }
@@ -47,5 +47,11 @@ export type TurnControl = {
47
47
  } | {
48
48
  type: 'end';
49
49
  reason: string;
50
+ } | {
51
+ type: 'escalate';
52
+ reason: string;
53
+ } | {
54
+ type: 'recover';
55
+ reason?: string;
50
56
  };
51
57
  export {};
package/package.json CHANGED
@@ -6,7 +6,7 @@
6
6
  "url": "git+https://github.com/kuralle/kuralle-agents.git",
7
7
  "directory": "packages/kuralle-core"
8
8
  },
9
- "version": "0.3.7",
9
+ "version": "0.3.8",
10
10
  "description": "A framework for structured conversational AI agents",
11
11
  "publishConfig": {
12
12
  "access": "public"
@@ -97,7 +97,7 @@
97
97
  "dotenv": "^16.4.0",
98
98
  "typescript": "^5.3.0",
99
99
  "zod": "^3.23.0",
100
- "@kuralle-agents/realtime-audio": "0.3.7"
100
+ "@kuralle-agents/realtime-audio": "0.3.8"
101
101
  },
102
102
  "dependencies": {
103
103
  "chrono-node": "^2.6.0",