@kuralle-agents/core 0.15.0 → 0.17.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.
@@ -1,6 +1,6 @@
1
1
  import { popFlowPark, runCollectDigression } from './collectDigression.js';
2
2
  import { parseConfirmation } from './confirmParse.js';
3
- import { inferRequiredFields } from './extraction.js';
3
+ import { inferRequiredFields, resetCollect } from './extraction.js';
4
4
  import { hasPendingUserInput, setPendingUserInput } from '../runtime/channels/inputBuffer.js';
5
5
  import { userInputToText } from '../runtime/userInput.js';
6
6
  import { collectUntilComplete } from './collectUntilComplete.js';
@@ -12,7 +12,7 @@ import { evaluateReplyControl } from './controlEvaluator.js';
12
12
  import { runNodeVerify, VerifyBlockedError } from './verify.js';
13
13
  import { loadRecordedSteps } from '../runtime/durable/replay.js';
14
14
  import { persistTurnUsageFromTurn } from '../runtime/turnTokenUsage.js';
15
- import { isApprovalDenial, isControlFlowSignal } from '../runtime/controlFlowSignal.js';
15
+ import { isApprovalDenial, isControlFlowSignal, isRecoverableToolError } from '../runtime/controlFlowSignal.js';
16
16
  import { emitInteractiveOnNodeEnter } from './emitInteractive.js';
17
17
  import { appendConversationAudit } from '../audit/record.js';
18
18
  import { appendSafeAssistantMessage, degradeFlowError, findEscalateNode, } from './degrade.js';
@@ -304,7 +304,15 @@ export async function runFlow(flow, run, driver, ctx, agent) {
304
304
  }
305
305
  const edgeCounts = new Map();
306
306
  const maxOscillations = flow.maxOscillations ?? 2;
307
+ // The node to re-collect from when an action throws a recoverable error (bad referent,
308
+ // missing precondition). Tracked as the most recent collect node visited in THIS flow
309
+ // invocation. If an action runs before any collect (no node can re-collect), a
310
+ // recoverable error degrades exactly as a fatal one would.
311
+ let lastCollectNode;
307
312
  for (;;) {
313
+ if (isCollectNode(node)) {
314
+ lastCollectNode = node;
315
+ }
308
316
  let transition;
309
317
  try {
310
318
  transition = await dispatchNode(node, run, driver, ctx, agent, flow);
@@ -316,6 +324,25 @@ export async function runFlow(flow, run, driver, ctx, agent) {
316
324
  if (isControlFlowSignal(error) || isApprovalDenial(error)) {
317
325
  throw error;
318
326
  }
327
+ if (isRecoverableToolError(error) && lastCollectNode) {
328
+ // The action node called a tool imperatively, so there is no model tool-call to
329
+ // attach a result to. Carry the message to the model as a system note (the next
330
+ // extraction/reply turn reads it from history), clear the offending collect's cache
331
+ // so re-entry genuinely re-collects instead of re-completing with the bad value, and
332
+ // return to that node. With the cache cleared and the turn's input already consumed
333
+ // by the collect that fed this action, collectUntilComplete emits its `ask` and
334
+ // parks on awaitingUser — a real re-ask, not an end.
335
+ resetCollect(run.state, lastCollectNode.id);
336
+ const note = {
337
+ role: 'system',
338
+ content: `Action "${node.id}" could not complete: ${error.message}. Re-collect the affected input from the user before retrying.`,
339
+ };
340
+ run.messages = [...run.messages, note];
341
+ node = lastCollectNode;
342
+ run.activeNode = node.id;
343
+ await ctx.runStore.putRunState(run);
344
+ continue;
345
+ }
319
346
  const message = error instanceof Error ? error.message : String(error);
320
347
  ctx.emit({ channel: 'client', type: 'error', payload: { error: message } });
321
348
  return degradeFlowError(flow, registry, run, driver, ctx, (n, r, d, c) => dispatchNode(n, r, d, c, agent, flow));
@@ -27,6 +27,13 @@ import { type TraceSink, type TraceStore } from '../tracing/TraceStore.js';
27
27
  * that emits no text is indistinguishable from the agent having crashed.
28
28
  */
29
29
  export declare const HANDOFF_TO_HUMAN_MESSAGE = "I'm bringing a colleague into this \u2014 they'll pick it up from here.";
30
+ /**
31
+ * What the user is told on any turn that arrives WHILE a run is already held for a human.
32
+ * The agent does not re-run for these turns — without a live `kuralle resume` the session
33
+ * would otherwise re-escalate every message regardless of topic. Distinct from
34
+ * `HANDOFF_TO_HUMAN_MESSAGE`, which is the one-time notice emitted on the escalating turn.
35
+ */
36
+ export declare const HELD_FOR_HUMAN_MESSAGE = "A colleague is already handling this \u2014 I'll pick it back up as soon as they're done.";
30
37
  export interface TracingConfig {
31
38
  enabled?: boolean;
32
39
  store?: TraceStore;
@@ -41,6 +41,13 @@ import { runHookSafely } from './runHookSafely.js';
41
41
  * that emits no text is indistinguishable from the agent having crashed.
42
42
  */
43
43
  export const HANDOFF_TO_HUMAN_MESSAGE = "I'm bringing a colleague into this — they'll pick it up from here.";
44
+ /**
45
+ * What the user is told on any turn that arrives WHILE a run is already held for a human.
46
+ * The agent does not re-run for these turns — without a live `kuralle resume` the session
47
+ * would otherwise re-escalate every message regardless of topic. Distinct from
48
+ * `HANDOFF_TO_HUMAN_MESSAGE`, which is the one-time notice emitted on the escalating turn.
49
+ */
50
+ export const HELD_FOR_HUMAN_MESSAGE = "A colleague is already handling this — I'll pick it back up as soon as they're done.";
44
51
  export class Runtime {
45
52
  config;
46
53
  agentsById;
@@ -97,9 +104,18 @@ export class Runtime {
97
104
  // were observed producing empty output while silently firing transfer_to_agent, which
98
105
  // is indistinguishable from an outage.
99
106
  let sawUserText = false;
107
+ // Handoff targets already announced this turn. `hostLoop` emits a handoff part when
108
+ // the transfer control tool fires, and the terminal branch below emits one too —
109
+ // but only SOME terminal handoffs come through hostLoop's control path (a validator
110
+ // escalate does not), so neither emitter can be deleted. Deduplicate instead: the
111
+ // double emit produced a spurious `human -> human` self-edge in the trace, because
112
+ // by the second emit the recorder's current agent was already the target.
113
+ const announcedHandoffs = new Set();
100
114
  const emit = (part) => {
101
115
  if (part.type === 'text-delta' && part.payload.delta)
102
116
  sawUserText = true;
117
+ if (part.type === 'handoff')
118
+ announcedHandoffs.add(part.payload.targetAgent);
103
119
  recorder?.record(part);
104
120
  if (part.type === 'done')
105
121
  this.flushTraceSinks();
@@ -155,6 +171,13 @@ export class Runtime {
155
171
  // THIS turn's consumption (delta), not the running session total (see the
156
172
  // per-turn scope requirement in the observability guide).
157
173
  const usageBaseline = readCumulativeUsage(freshRunState.state);
174
+ // A run parked on a terminal-handoff escalation (status 'paused' with NO `waitingFor`)
175
+ // is held for a human until `resumeFromEscalation`. It must not re-run the agent —
176
+ // without this gate every later turn re-escalated, recycling the prior escalation's
177
+ // reason even for unrelated messages. `waitingFor` is the discriminator: suspend and
178
+ // approval waits also set status 'paused' but DO set `waitingFor` and resume by
179
+ // signal; gating those would hang every approval and every suspended tool.
180
+ const heldForHuman = freshRunState.status === 'paused' && !freshRunState.waitingFor;
158
181
  const model = opened.agent.model ?? this.defaultModel;
159
182
  if (!model) {
160
183
  throw new Error('Runtime requires agent.model or config.defaultModel');
@@ -204,178 +227,213 @@ export class Runtime {
204
227
  let terminalOutcome;
205
228
  let overflowRetried = false;
206
229
  try {
207
- turnLoop: for (;;) {
208
- try {
209
- loopResult = await hostLoop({
210
- agent: activeAgent,
211
- run: runCtx.runState,
212
- driver,
213
- ctx: runCtx,
214
- classify: this.config.hostClassify ??
215
- (this.config.hostSelect ? adaptHostSelect(this.config.hostSelect) : undefined),
216
- });
217
- }
218
- catch (error) {
219
- if (!overflowRetried && this.config.compaction && isContextOverflowError(error)) {
220
- overflowRetried = true;
221
- await this.recoverFromOverflow(runCtx, activeAgent, emit);
222
- continue turnLoop;
230
+ if (heldForHuman) {
231
+ // Mirror the degraded-turn shape so TurnHandle consumers see a normal text +
232
+ // `done` and do not hang: the hold message streams once, the turn is persisted,
233
+ // and the existing `finally` emits `done` and runs closeRun. The agent never runs.
234
+ const heldId = crypto.randomUUID();
235
+ emit({ channel: 'client', type: 'text-start', payload: { id: heldId } });
236
+ emit({ channel: 'client', type: 'text-delta', payload: { id: heldId, delta: HELD_FOR_HUMAN_MESSAGE } });
237
+ emit({ channel: 'client', type: 'text-end', payload: { id: heldId } });
238
+ runCtx.runState.messages = [
239
+ ...runCtx.runState.messages,
240
+ { role: 'assistant', content: HELD_FOR_HUMAN_MESSAGE },
241
+ ];
242
+ await runCtx.runStore.putRunState(runCtx.runState);
243
+ // NOT a terminal outcome: the run stays `paused` (closeRun only flips to
244
+ // `finished` when terminalOutcome is set). Setting it would both break the
245
+ // hold — the next turn would no longer see `paused` — and trip a stale-version
246
+ // outcome mark across multi-turn sessions.
247
+ loopResult = { kind: 'ended', reason: 'held_for_human' };
248
+ }
249
+ else {
250
+ turnLoop: for (;;) {
251
+ try {
252
+ loopResult = await hostLoop({
253
+ agent: activeAgent,
254
+ run: runCtx.runState,
255
+ driver,
256
+ ctx: runCtx,
257
+ classify: this.config.hostClassify ??
258
+ (this.config.hostSelect ? adaptHostSelect(this.config.hostSelect) : undefined),
259
+ });
223
260
  }
224
- throw error;
225
- }
226
- if (loopResult.kind === 'handoff') {
227
- if (this.terminalHandoffTargets.has(loopResult.to)) {
228
- // This emit is load-bearing for TERMINAL targets specifically. A terminal
229
- // handoff breaks out of the loop here, before hostLoop's own emit and before
230
- // handoffHistory is appended — so this is the only handoff part a client ever
231
- // sees for an escalation. Removing it as "redundant" silently produced zero
232
- // handoff parts for every escalation; escalation.test.ts catches it.
233
- //
234
- // The self-edge seen live (handoff human->human, two spans on one turn) is a
235
- // real but SEPARATE defect on the non-terminal path fix it there, not by
236
- // deleting this.
237
- emit({
238
- channel: 'internal',
239
- type: 'handoff',
240
- payload: { targetAgent: loopResult.to, reason: loopResult.reason },
261
+ catch (error) {
262
+ if (!overflowRetried && this.config.compaction && isContextOverflowError(error)) {
263
+ overflowRetried = true;
264
+ await this.recoverFromOverflow(runCtx, activeAgent, emit);
265
+ continue turnLoop;
266
+ }
267
+ throw error;
268
+ }
269
+ if (loopResult.kind === 'handoff') {
270
+ if (this.terminalHandoffTargets.has(loopResult.to)) {
271
+ // This emit is load-bearing for TERMINAL targets specifically. A terminal
272
+ // handoff breaks out of the loop here, before hostLoop's own emit and before
273
+ // handoffHistory is appended — so this is the only handoff part a client ever
274
+ // sees for an escalation. Removing it as "redundant" silently produced zero
275
+ // handoff parts for every escalation; escalation.test.ts catches it.
276
+ //
277
+ // The self-edge seen live (handoff human->human, two spans on one turn) is a
278
+ // real but SEPARATE defect on the non-terminal path — fix it there, not by
279
+ // deleting this.
280
+ if (!announcedHandoffs.has(loopResult.to)) {
281
+ emit({
282
+ channel: 'internal',
283
+ type: 'handoff',
284
+ payload: { targetAgent: loopResult.to, reason: loopResult.reason },
285
+ });
286
+ }
287
+ // Record the terminal handoff in handoffHistory. This branch used to `break`
288
+ // before the non-terminal push below, so the array stayed empty and
289
+ // isHandoffOscillating could never see repeated escalations. The oscillation
290
+ // check only runs on the non-terminal branch and only counts consecutive
291
+ // same-pair hops, so appending `agent -> human` here cannot trip a false
292
+ // oscillation on a later legitimate handoff to a different target.
293
+ opened.session.handoffHistory.push({
294
+ from: runCtx.runState.activeAgentId,
295
+ to: loopResult.to,
296
+ reason: loopResult.reason ?? 'handoff',
297
+ timestamp: new Date(),
298
+ });
299
+ runCtx.runState.status = 'paused';
300
+ // Never hand off in silence. dispatchEscalation returns immediately when no
301
+ // escalation handler is configured, so without this the turn ends with no
302
+ // assistant text at all and the user cannot tell an escalation from a crash.
303
+ if (!sawUserText) {
304
+ const handoffId = crypto.randomUUID();
305
+ emit({ channel: 'client', type: 'text-start', payload: { id: handoffId } });
306
+ emit({
307
+ channel: 'client',
308
+ type: 'text-delta',
309
+ payload: { id: handoffId, delta: HANDOFF_TO_HUMAN_MESSAGE },
310
+ });
311
+ emit({ channel: 'client', type: 'text-end', payload: { id: handoffId } });
312
+ runCtx.runState.messages = [
313
+ ...runCtx.runState.messages,
314
+ { role: 'assistant', content: HANDOFF_TO_HUMAN_MESSAGE },
315
+ ];
316
+ }
317
+ await runCtx.runStore.putRunState(runCtx.runState);
318
+ await this.dispatchEscalation(runCtx, activeAgent, { reason: loopResult.reason ?? 'handoff_to_human', category: loopResult.category }, emit, { setLatch: false });
319
+ break;
320
+ }
321
+ handoffCount += 1;
322
+ if (handoffCount > this.maxHandoffs) {
323
+ throw new Error(`maxHandoffs exceeded (${this.maxHandoffs})`);
324
+ }
325
+ // Cross-turn ping-pong safeguard (handoffCount resets each turn, so it
326
+ // can't catch A↔B oscillation spread across turns). This is a bound
327
+ // ABOVE maxHandoffs: within-run runaway is caught by the maxHandoffs
328
+ // check above; oscillation only fires for same-pair accumulation in the
329
+ // persisted handoffHistory beyond that, so it never pre-empts maxHandoffs.
330
+ if (isHandoffOscillating(opened.session.handoffHistory, runCtx.runState.activeAgentId, loopResult.to, this.maxHandoffs + 1)) {
331
+ throw new Error(`Handoff oscillation detected between "${runCtx.runState.activeAgentId}" and "${loopResult.to}"`);
332
+ }
333
+ const target = this.agentsById.get(loopResult.to);
334
+ if (!target) {
335
+ throw new Error(`Handoff target agent not found: ${loopResult.to}`);
336
+ }
337
+ opened.session.handoffHistory.push({
338
+ from: runCtx.runState.activeAgentId,
339
+ to: loopResult.to,
340
+ reason: loopResult.reason ?? 'handoff',
341
+ timestamp: new Date(),
241
342
  });
242
- runCtx.runState.status = 'paused';
243
- // Never hand off in silence. dispatchEscalation returns immediately when no
244
- // escalation handler is configured, so without this the turn ends with no
245
- // assistant text at all and the user cannot tell an escalation from a crash.
246
- if (!sawUserText) {
247
- const handoffId = crypto.randomUUID();
248
- emit({ channel: 'client', type: 'text-start', payload: { id: handoffId } });
249
- emit({
250
- channel: 'client',
251
- type: 'text-delta',
252
- payload: { id: handoffId, delta: HANDOFF_TO_HUMAN_MESSAGE },
343
+ const handoffTarget = loopResult.to;
344
+ const routeFilter = activeAgent.routes?.find((r) => r.agent === handoffTarget)?.filter;
345
+ const inputFilter = routeFilter ?? this.config.handoffInputFilter;
346
+ if (inputFilter) {
347
+ const filtered = await inputFilter({
348
+ messages: runCtx.runState.messages,
349
+ workingMemory: runCtx.session.workingMemory,
350
+ sourceAgentId: runCtx.runState.activeAgentId,
351
+ targetAgentId: handoffTarget,
352
+ reason: loopResult.reason,
253
353
  });
254
- emit({ channel: 'client', type: 'text-end', payload: { id: handoffId } });
255
- runCtx.runState.messages = [
256
- ...runCtx.runState.messages,
257
- { role: 'assistant', content: HANDOFF_TO_HUMAN_MESSAGE },
258
- ];
354
+ runCtx.runState.messages = filtered.messages;
355
+ runCtx.session.workingMemory = filtered.workingMemory;
259
356
  }
260
- await runCtx.runStore.putRunState(runCtx.runState);
261
- await this.dispatchEscalation(runCtx, activeAgent, { reason: loopResult.reason ?? 'handoff_to_human', category: loopResult.category }, emit, { setLatch: false });
262
- break;
263
- }
264
- handoffCount += 1;
265
- if (handoffCount > this.maxHandoffs) {
266
- throw new Error(`maxHandoffs exceeded (${this.maxHandoffs})`);
267
- }
268
- // Cross-turn ping-pong safeguard (handoffCount resets each turn, so it
269
- // can't catch A↔B oscillation spread across turns). This is a bound
270
- // ABOVE maxHandoffs: within-run runaway is caught by the maxHandoffs
271
- // check above; oscillation only fires for same-pair accumulation in the
272
- // persisted handoffHistory beyond that, so it never pre-empts maxHandoffs.
273
- if (isHandoffOscillating(opened.session.handoffHistory, runCtx.runState.activeAgentId, loopResult.to, this.maxHandoffs + 1)) {
274
- throw new Error(`Handoff oscillation detected between "${runCtx.runState.activeAgentId}" and "${loopResult.to}"`);
275
- }
276
- const target = this.agentsById.get(loopResult.to);
277
- if (!target) {
278
- throw new Error(`Handoff target agent not found: ${loopResult.to}`);
279
- }
280
- opened.session.handoffHistory.push({
281
- from: runCtx.runState.activeAgentId,
282
- to: loopResult.to,
283
- reason: loopResult.reason ?? 'handoff',
284
- timestamp: new Date(),
285
- });
286
- const handoffTarget = loopResult.to;
287
- const routeFilter = activeAgent.routes?.find((r) => r.agent === handoffTarget)?.filter;
288
- const inputFilter = routeFilter ?? this.config.handoffInputFilter;
289
- if (inputFilter) {
290
- const filtered = await inputFilter({
291
- messages: runCtx.runState.messages,
292
- workingMemory: runCtx.session.workingMemory,
293
- sourceAgentId: runCtx.runState.activeAgentId,
294
- targetAgentId: handoffTarget,
295
- reason: loopResult.reason,
357
+ runCtx.runState.activeAgentId = loopResult.to;
358
+ activeAgent = target;
359
+ const targetSurface = await buildAgentToolSurface(target, opened.session, {
360
+ configTools: this.config.tools,
361
+ knowledgeProvider,
362
+ defaultWorkingMemoryStore: this.config.defaultWorkingMemoryStore,
363
+ });
364
+ runCtx.autoRetrieve = knowledgeProvider
365
+ ? buildAutoRetrieveProvider(knowledgeProvider, target)
366
+ : undefined;
367
+ runCtx.globalTools = targetSurface.globalTools;
368
+ runCtx.skillPrompt = targetSurface.skillPrompt;
369
+ runCtx.workingMemoryPrompt = appendGoalsPrompt(targetSurface.workingMemoryPrompt, runCtx.session.workingMemory);
370
+ runCtx.workingMemoryTools = targetSurface.workingMemoryTools;
371
+ runCtx.fs = targetSurface.resolvedWorkspace?.fs;
372
+ runCtx.memoryService = this.config.memoryService
373
+ ? buildMemoryService(this.config.memoryService, target)
374
+ : undefined;
375
+ const targetPolicies = resolveAgentPolicies(target);
376
+ const targetModel = target.model ?? this.defaultModel;
377
+ if (!targetModel) {
378
+ throw new Error('Runtime requires agent.model or config.defaultModel');
379
+ }
380
+ runCtx.baseInstructions =
381
+ (this.config.silentHandoff ?? true)
382
+ ? applyHandoffContinuation(target.instructions)
383
+ : target.instructions;
384
+ runCtx.model = targetModel;
385
+ runCtx.controlModel = target.controlModel ?? targetModel;
386
+ runCtx.outOfBandControl = resolveOutOfBandControl(target);
387
+ runCtx.limits = targetPolicies.limits;
388
+ runCtx.refinementPolicies = targetPolicies.refinementPolicies;
389
+ runCtx.validationPolicies = targetPolicies.validationPolicies;
390
+ runCtx.inputProcessors = targetPolicies.inputProcessors;
391
+ runCtx.outputProcessors = targetPolicies.outputProcessors;
392
+ runCtx.toolExecutor = new CoreToolExecutor({
393
+ tools: targetSurface.executorTools,
394
+ enforcer: targetPolicies.enforcer,
395
+ agentId: target.id,
396
+ onInterim: (message) => {
397
+ const id = crypto.randomUUID();
398
+ emit({ channel: 'client', type: 'text-start', payload: { id } });
399
+ emit({
400
+ channel: 'client',
401
+ type: 'text-delta',
402
+ payload: { id, delta: message },
403
+ });
404
+ emit({ channel: 'client', type: 'text-end', payload: { id } });
405
+ },
406
+ onChunk: (chunk, toolName, toolCallId) => {
407
+ emit({
408
+ channel: 'internal',
409
+ type: 'tool-result',
410
+ payload: { toolName, result: chunk, toolCallId, preliminary: true },
411
+ });
412
+ },
296
413
  });
297
- runCtx.runState.messages = filtered.messages;
298
- runCtx.session.workingMemory = filtered.workingMemory;
414
+ await runCtx.runStore.putRunState(runCtx.runState);
415
+ continue;
299
416
  }
300
- runCtx.runState.activeAgentId = loopResult.to;
301
- activeAgent = target;
302
- const targetSurface = await buildAgentToolSurface(target, opened.session, {
303
- configTools: this.config.tools,
304
- knowledgeProvider,
305
- defaultWorkingMemoryStore: this.config.defaultWorkingMemoryStore,
306
- });
307
- runCtx.autoRetrieve = knowledgeProvider
308
- ? buildAutoRetrieveProvider(knowledgeProvider, target)
309
- : undefined;
310
- runCtx.globalTools = targetSurface.globalTools;
311
- runCtx.skillPrompt = targetSurface.skillPrompt;
312
- runCtx.workingMemoryPrompt = appendGoalsPrompt(targetSurface.workingMemoryPrompt, runCtx.session.workingMemory);
313
- runCtx.workingMemoryTools = targetSurface.workingMemoryTools;
314
- runCtx.fs = targetSurface.resolvedWorkspace?.fs;
315
- runCtx.memoryService = this.config.memoryService
316
- ? buildMemoryService(this.config.memoryService, target)
317
- : undefined;
318
- const targetPolicies = resolveAgentPolicies(target);
319
- const targetModel = target.model ?? this.defaultModel;
320
- if (!targetModel) {
321
- throw new Error('Runtime requires agent.model or config.defaultModel');
417
+ if (loopResult.kind === 'ended') {
418
+ terminalOutcome = 'resolved';
419
+ break;
322
420
  }
323
- runCtx.baseInstructions =
324
- (this.config.silentHandoff ?? true)
325
- ? applyHandoffContinuation(target.instructions)
326
- : target.instructions;
327
- runCtx.model = targetModel;
328
- runCtx.controlModel = target.controlModel ?? targetModel;
329
- runCtx.outOfBandControl = resolveOutOfBandControl(target);
330
- runCtx.limits = targetPolicies.limits;
331
- runCtx.refinementPolicies = targetPolicies.refinementPolicies;
332
- runCtx.validationPolicies = targetPolicies.validationPolicies;
333
- runCtx.inputProcessors = targetPolicies.inputProcessors;
334
- runCtx.outputProcessors = targetPolicies.outputProcessors;
335
- runCtx.toolExecutor = new CoreToolExecutor({
336
- tools: targetSurface.executorTools,
337
- enforcer: targetPolicies.enforcer,
338
- agentId: target.id,
339
- onInterim: (message) => {
340
- const id = crypto.randomUUID();
341
- emit({ channel: 'client', type: 'text-start', payload: { id } });
342
- emit({
343
- channel: 'client',
344
- type: 'text-delta',
345
- payload: { id, delta: message },
346
- });
347
- emit({ channel: 'client', type: 'text-end', payload: { id } });
348
- },
349
- onChunk: (chunk, toolName, toolCallId) => {
350
- emit({
351
- channel: 'internal',
352
- type: 'tool-result',
353
- payload: { toolName, result: chunk, toolCallId, preliminary: true },
354
- });
355
- },
356
- });
357
- await runCtx.runStore.putRunState(runCtx.runState);
358
- continue;
359
- }
360
- if (loopResult.kind === 'ended') {
361
- terminalOutcome = 'resolved';
362
- break;
363
- }
364
- if (loopResult.kind === 'paused') {
365
- if (runCtx.runState.waitingFor?.signalName === '__escalate') {
366
- // Flow escalate() parks on the durable signal — notify the human
367
- // side now; the latch keeps the post-resume terminal handoff from
368
- // notifying a second time.
369
- const meta = runCtx.runState.waitingFor.meta;
370
- await this.dispatchEscalation(runCtx, activeAgent, { reason: String(meta?.reason ?? 'flow_escalation') }, emit, { setLatch: true });
421
+ if (loopResult.kind === 'paused') {
422
+ if (runCtx.runState.waitingFor?.signalName === '__escalate') {
423
+ // Flow escalate() parks on the durable signal — notify the human
424
+ // side now; the latch keeps the post-resume terminal handoff from
425
+ // notifying a second time.
426
+ const meta = runCtx.runState.waitingFor.meta;
427
+ await this.dispatchEscalation(runCtx, activeAgent, { reason: String(meta?.reason ?? 'flow_escalation') }, emit, { setLatch: true });
428
+ }
429
+ break;
371
430
  }
372
431
  break;
373
432
  }
374
- break;
433
+ // Post-turn maintenance: text already streamed, so the summarizer call
434
+ // is off the user's latency path; the NEXT turn starts compact.
435
+ await this.applyCompaction(runCtx, activeAgent, emit, false);
375
436
  }
376
- // Post-turn maintenance: text already streamed, so the summarizer call
377
- // is off the user's latency path; the NEXT turn starts compact.
378
- await this.applyCompaction(runCtx, activeAgent, emit, false);
379
437
  }
380
438
  catch (error) {
381
439
  await runHookSafely('onError', () => this.hooks?.onError?.(runCtx, error));
@@ -2,7 +2,7 @@ import { classifyControl } from '../../flow/classifyControl.js';
2
2
  import { toolDeniedResult, toolErrorResult } from '../../tools/controlResults.js';
3
3
  import { idempotencyKey, logicalRunId } from '../durable/idempotency.js';
4
4
  import { findStepByKey } from '../durable/replay.js';
5
- import { isApprovalDenial, isControlFlowSignal } from '../controlFlowSignal.js';
5
+ import { isApprovalDenial, isControlFlowSignal, isRecoverableToolError } from '../controlFlowSignal.js';
6
6
  export async function executeModelToolCall(ctx, call, localTools, durableOpts) {
7
7
  try {
8
8
  const localTool = localTools?.[call.toolName];
@@ -36,6 +36,12 @@ export async function executeModelToolCall(ctx, call, localTools, durableOpts) {
36
36
  // the turn dies and the user never hears why. No client error part: nothing broke.
37
37
  return { result: toolDeniedResult(error.toolName, error.by), failed: true };
38
38
  }
39
+ if (isRecoverableToolError(error)) {
40
+ // The model can correct this (bad referent, missing precondition): return the message
41
+ // as a tool result so it can retry. Not a malfunction, so no client error part —
42
+ // same posture as an approval denial.
43
+ return { result: toolErrorResult(error), failed: true };
44
+ }
39
45
  const message = error instanceof Error ? error.message : String(error);
40
46
  ctx.emit({ channel: 'client', type: 'error', payload: { error: message } });
41
47
  return { result: toolErrorResult(error), failed: true };
@@ -29,6 +29,12 @@ export async function closeRun(options) {
29
29
  // otherwise lacks assistant turns and keeps pre-guardrail (unredacted)
30
30
  // user input written at openRun.
31
31
  latest.messages = [...runState.messages];
32
+ // Persist handoff history accumulated on the run's working session this turn
33
+ // (terminal + non-terminal handoffs alike). Without this the in-memory pushes
34
+ // were silently dropped: stores that clone on get/save (e.g. MemoryStore) hand
35
+ // back a fresh snapshot here, and the previous mutator never copied handoffHistory
36
+ // across — so isHandoffOscillating's cross-turn safeguard never saw prior turns.
37
+ latest.handoffHistory = session.handoffHistory;
32
38
  syncPendingUserInput(ctx.session, latest);
33
39
  });
34
40
  }
@@ -1,4 +1,4 @@
1
- import { ToolApprovalDeniedError } from '../tools/effect/errors.js';
1
+ import { RecoverableToolError, ToolApprovalDeniedError } from '../tools/effect/errors.js';
2
2
  /**
3
3
  * The run is not finished: stop the turn, keep the journal, resume when a signal arrives.
4
4
  * It unwinds the stack like an error but is not a failure, so it must never be reported to
@@ -22,3 +22,9 @@ export declare function isControlFlowSignal(error: unknown): boolean;
22
22
  * call the tool and can catch it or branch on `ctx.approve()` instead.
23
23
  */
24
24
  export declare function isApprovalDenial(error: unknown): error is ToolApprovalDeniedError;
25
+ /**
26
+ * A tool failed in a way the model can correct (bad referent, missing precondition). Not a
27
+ * control-flow signal and not a fatal fault: on the flow path it routes back to re-collecting
28
+ * the offending input rather than degrading the turn.
29
+ */
30
+ export declare function isRecoverableToolError(error: unknown): error is RecoverableToolError;
@@ -1,5 +1,5 @@
1
1
  import { SuspendError } from './durable/RunStore.js';
2
- import { ToolApprovalDeniedError } from '../tools/effect/errors.js';
2
+ import { RecoverableToolError, ToolApprovalDeniedError } from '../tools/effect/errors.js';
3
3
  /**
4
4
  * The run is not finished: stop the turn, keep the journal, resume when a signal arrives.
5
5
  * It unwinds the stack like an error but is not a failure, so it must never be reported to
@@ -27,3 +27,11 @@ export function isControlFlowSignal(error) {
27
27
  export function isApprovalDenial(error) {
28
28
  return error instanceof ToolApprovalDeniedError;
29
29
  }
30
+ /**
31
+ * A tool failed in a way the model can correct (bad referent, missing precondition). Not a
32
+ * control-flow signal and not a fatal fault: on the flow path it routes back to re-collecting
33
+ * the offending input rather than degrading the turn.
34
+ */
35
+ export function isRecoverableToolError(error) {
36
+ return error instanceof RecoverableToolError;
37
+ }
@@ -4,6 +4,21 @@ export declare class ToolTimeoutError extends Error {
4
4
  readonly timeoutMs: number;
5
5
  constructor(toolName: string, timeoutMs: number);
6
6
  }
7
+ /**
8
+ * Thrown by a tool's `execute` (or a guard wrapping it) when the call failed for a
9
+ * reason the model can correct: a referent that does not exist ("Unknown unit '12B'"),
10
+ * a value out of range, a missing precondition the user can supply. Distinct from a
11
+ * fatal fault (network down, schema broken) in that the model should see the message
12
+ * and retry — not be told "something went wrong on my side".
13
+ *
14
+ * On the model path it is returned to the model as a tool result (the model self-corrects).
15
+ * On the flow path it is the signal to re-collect the offending input instead of
16
+ * degrading the flow — see `runFlow`. Detected by type (`isRecoverableToolError`), never
17
+ * by string-matching the message.
18
+ */
19
+ export declare class RecoverableToolError extends Error {
20
+ constructor(message: string);
21
+ }
7
22
  /** Thrown when a human denies a tool call that requires approval. */
8
23
  export declare class ToolApprovalDeniedError extends Error {
9
24
  readonly toolName: string;
@@ -9,6 +9,24 @@ export class ToolTimeoutError extends Error {
9
9
  this.timeoutMs = timeoutMs;
10
10
  }
11
11
  }
12
+ /**
13
+ * Thrown by a tool's `execute` (or a guard wrapping it) when the call failed for a
14
+ * reason the model can correct: a referent that does not exist ("Unknown unit '12B'"),
15
+ * a value out of range, a missing precondition the user can supply. Distinct from a
16
+ * fatal fault (network down, schema broken) in that the model should see the message
17
+ * and retry — not be told "something went wrong on my side".
18
+ *
19
+ * On the model path it is returned to the model as a tool result (the model self-corrects).
20
+ * On the flow path it is the signal to re-collect the offending input instead of
21
+ * degrading the flow — see `runFlow`. Detected by type (`isRecoverableToolError`), never
22
+ * by string-matching the message.
23
+ */
24
+ export class RecoverableToolError extends Error {
25
+ constructor(message) {
26
+ super(message);
27
+ this.name = 'RecoverableToolError';
28
+ }
29
+ }
12
30
  /** Thrown when a human denies a tool call that requires approval. */
13
31
  export class ToolApprovalDeniedError extends Error {
14
32
  toolName;
package/package.json CHANGED
@@ -6,7 +6,7 @@
6
6
  "url": "git+https://github.com/kuralle/kuralle-agents.git",
7
7
  "directory": "packages/core"
8
8
  },
9
- "version": "0.15.0",
9
+ "version": "0.17.0",
10
10
  "description": "A framework for structured conversational AI agents",
11
11
  "publishConfig": {
12
12
  "access": "public"
@@ -103,7 +103,7 @@
103
103
  "typescript": "^5.3.0",
104
104
  "vitest": "^3.2.4",
105
105
  "zod": "^4.0.0",
106
- "@kuralle-agents/fs": "0.14.1"
106
+ "@kuralle-agents/fs": "0.17.0"
107
107
  },
108
108
  "dependencies": {
109
109
  "chrono-node": "^2.6.0"