@kuralle-agents/core 0.14.0 → 0.16.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.
- package/dist/flow/collectUntilComplete.js +26 -3
- package/dist/flow/extraction.d.ts +19 -0
- package/dist/flow/extraction.js +52 -3
- package/dist/flow/runFlow.d.ts +11 -0
- package/dist/flow/runFlow.js +62 -1
- package/dist/runtime/Runtime.d.ts +13 -0
- package/dist/runtime/Runtime.js +233 -136
- package/dist/runtime/channels/executeModelTool.js +7 -1
- package/dist/runtime/channels/streaming/speakGated.js +9 -0
- package/dist/runtime/closeRun.js +6 -0
- package/dist/runtime/controlFlowSignal.d.ts +7 -1
- package/dist/runtime/controlFlowSignal.js +9 -1
- package/dist/runtime/hostLoop.js +13 -4
- package/dist/tools/effect/errors.d.ts +15 -0
- package/dist/tools/effect/errors.js +18 -0
- package/dist/tools/fs/createFsTool.js +2 -1
- package/package.json +2 -2
|
@@ -3,7 +3,7 @@ import { persistTurnUsageFromTurn } from '../runtime/turnTokenUsage.js';
|
|
|
3
3
|
import { hasPendingUserInput } from '../runtime/channels/inputBuffer.js';
|
|
4
4
|
import { userInputToText } from '../runtime/userInput.js';
|
|
5
5
|
import { resolveCollectExtractionNode } from './nodeBuilders.js';
|
|
6
|
-
import { computeMissingFields, createExtractionSubmitTool, getCollectData, incrementCollectTurns, emitExtractionTelemetry, mergeTurnExtraction, projectCollectData, schemaSatisfied, } from './extraction.js';
|
|
6
|
+
import { computeMissingFields, createExtractionSubmitTool, getCollectData, incrementCollectTurns, emitExtractionTelemetry, mergeTurnExtraction, projectCollectData, schemaSatisfied, invalidCollectFields, setCollectData, } from './extraction.js';
|
|
7
7
|
import { normalizeTransition } from './normalizeTransition.js';
|
|
8
8
|
function appendAssistantMessage(run, text) {
|
|
9
9
|
if (!text.trim()) {
|
|
@@ -50,8 +50,31 @@ export async function collectUntilComplete(node, run, driver, ctx, options) {
|
|
|
50
50
|
const turns = incrementCollectTurns(run.state, node.id);
|
|
51
51
|
const maxTurns = node.maxTurns ?? 10;
|
|
52
52
|
if (turns > maxTurns) {
|
|
53
|
-
|
|
54
|
-
|
|
53
|
+
// Running out of turns is not the same as finishing. Calling onComplete here with
|
|
54
|
+
// required fields still missing hands the rest of the flow a half-filled record and
|
|
55
|
+
// lets an action node act on it — for an intake SOP that means creating a record
|
|
56
|
+
// against whatever happened to be in state. Complete only if the node is genuinely
|
|
57
|
+
// satisfied; otherwise this is a failure to collect, and a human should see it.
|
|
58
|
+
if (schemaSatisfied(node, run.state)) {
|
|
59
|
+
const data = projectCollectData(node, run.state);
|
|
60
|
+
return normalizeTransition(await node.onComplete(data, run.state));
|
|
61
|
+
}
|
|
62
|
+
const missing = computeMissingFields(node, getCollectData(run.state, node.id));
|
|
63
|
+
return normalizeTransition({
|
|
64
|
+
escalate: `Could not collect ${missing.join(', ')} for "${node.id}" after ${maxTurns} turns.`,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
// Drop any collected value the schema rejects before working out what to ask for.
|
|
68
|
+
// Without this a value that is present-but-invalid is neither missing (so it is never
|
|
69
|
+
// re-asked) nor satisfying (so the node never completes) — the loop would spin to
|
|
70
|
+
// maxTurns. Purging turns an invalid value back into a missing one, which the existing
|
|
71
|
+
// ask/extract machinery already knows how to chase.
|
|
72
|
+
const collected = getCollectData(run.state, node.id);
|
|
73
|
+
const invalid = invalidCollectFields(node, collected);
|
|
74
|
+
if (invalid.length > 0) {
|
|
75
|
+
for (const field of invalid)
|
|
76
|
+
delete collected[field];
|
|
77
|
+
setCollectData(run.state, node.id, collected);
|
|
55
78
|
}
|
|
56
79
|
const missingBefore = computeMissingFields(node, getCollectData(run.state, node.id));
|
|
57
80
|
const submitTool = createExtractionSubmitTool(node, missingBefore, {
|
|
@@ -1,10 +1,28 @@
|
|
|
1
|
+
import type { StandardSchemaV1 } from '../types/standard-schema.js';
|
|
1
2
|
import type { CollectNode, FlowState } from '../types/flow.js';
|
|
2
3
|
import type { Tool } from '../types/effectTool.js';
|
|
3
4
|
import type { StreamPart } from '../types/stream.js';
|
|
4
5
|
export declare function resetCollect(state: FlowState, nodeId: string): void;
|
|
5
6
|
export declare function getCollectData(state: FlowState, nodeId: string): Record<string, unknown>;
|
|
7
|
+
export declare function setCollectData(state: FlowState, nodeId: string, data: Record<string, unknown>): void;
|
|
6
8
|
export declare function incrementCollectTurns(state: FlowState, nodeId: string): number;
|
|
7
9
|
export declare function computeMissingFields(node: CollectNode, data: Record<string, unknown>): string[];
|
|
10
|
+
/**
|
|
11
|
+
* Fields whose collected value is present but does NOT satisfy the node's schema.
|
|
12
|
+
*
|
|
13
|
+
* Completion used to be presence-only: any non-empty value passed, so a model that invented
|
|
14
|
+
* an enum member (`urgency: "high"` against `z.enum(['emergency','urgent','routine'])`) or
|
|
15
|
+
* supplied the wrong primitive completed the node and the flow acted on it. The schema was
|
|
16
|
+
* only ever used to derive key names, never to check a value.
|
|
17
|
+
*
|
|
18
|
+
* Only validates synchronously — StandardSchema permits an async validator, and the
|
|
19
|
+
* completion gate is sync. Zod is sync, which covers every schema in this repo; an async
|
|
20
|
+
* validator degrades to the old presence-only behaviour rather than blocking.
|
|
21
|
+
*
|
|
22
|
+
* NOTE this catches SHAPE, not REFERENT. `unitId: "12B"` is a perfectly valid string; that a
|
|
23
|
+
* unit by that id does not exist is a domain fact only a tool boundary can know.
|
|
24
|
+
*/
|
|
25
|
+
export declare function invalidCollectFields(node: CollectNode, data: Record<string, unknown>): string[];
|
|
8
26
|
export declare function schemaSatisfied(node: CollectNode, state: FlowState): boolean;
|
|
9
27
|
export declare function projectCollectData(node: CollectNode, state: FlowState): unknown;
|
|
10
28
|
export declare function mergeExtractionData(current: Record<string, unknown>, incoming: Record<string, unknown>): Record<string, unknown>;
|
|
@@ -21,3 +39,4 @@ export declare function mergeTurnExtraction(node: CollectNode, state: FlowState,
|
|
|
21
39
|
result: unknown;
|
|
22
40
|
}>): MergeTurnExtractionResult;
|
|
23
41
|
export declare function emitExtractionTelemetry(node: CollectNode, state: FlowState, incoming: Record<string, unknown>, emit: (part: StreamPart) => void): void;
|
|
42
|
+
export declare function inferRequiredFields(schema: StandardSchemaV1): string[];
|
package/dist/flow/extraction.js
CHANGED
|
@@ -17,7 +17,7 @@ export function getCollectData(state, nodeId) {
|
|
|
17
17
|
}
|
|
18
18
|
return {};
|
|
19
19
|
}
|
|
20
|
-
function setCollectData(state, nodeId, data) {
|
|
20
|
+
export function setCollectData(state, nodeId, data) {
|
|
21
21
|
state[collectDataKey(nodeId)] = data;
|
|
22
22
|
}
|
|
23
23
|
function getCollectTurns(state, nodeId) {
|
|
@@ -33,9 +33,58 @@ export function computeMissingFields(node, data) {
|
|
|
33
33
|
const required = node.required ?? inferRequiredFields(node.schema);
|
|
34
34
|
return required.filter((field) => !fieldPopulated(data[field]));
|
|
35
35
|
}
|
|
36
|
+
/**
|
|
37
|
+
* Fields whose collected value is present but does NOT satisfy the node's schema.
|
|
38
|
+
*
|
|
39
|
+
* Completion used to be presence-only: any non-empty value passed, so a model that invented
|
|
40
|
+
* an enum member (`urgency: "high"` against `z.enum(['emergency','urgent','routine'])`) or
|
|
41
|
+
* supplied the wrong primitive completed the node and the flow acted on it. The schema was
|
|
42
|
+
* only ever used to derive key names, never to check a value.
|
|
43
|
+
*
|
|
44
|
+
* Only validates synchronously — StandardSchema permits an async validator, and the
|
|
45
|
+
* completion gate is sync. Zod is sync, which covers every schema in this repo; an async
|
|
46
|
+
* validator degrades to the old presence-only behaviour rather than blocking.
|
|
47
|
+
*
|
|
48
|
+
* NOTE this catches SHAPE, not REFERENT. `unitId: "12B"` is a perfectly valid string; that a
|
|
49
|
+
* unit by that id does not exist is a domain fact only a tool boundary can know.
|
|
50
|
+
*/
|
|
51
|
+
export function invalidCollectFields(node, data) {
|
|
52
|
+
const validate = node.schema['~standard']?.validate;
|
|
53
|
+
if (typeof validate !== 'function')
|
|
54
|
+
return [];
|
|
55
|
+
let result;
|
|
56
|
+
try {
|
|
57
|
+
result = validate(data);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return [];
|
|
61
|
+
}
|
|
62
|
+
if (result instanceof Promise)
|
|
63
|
+
return [];
|
|
64
|
+
const issues = result
|
|
65
|
+
?.issues;
|
|
66
|
+
if (!issues?.length)
|
|
67
|
+
return [];
|
|
68
|
+
const fields = new Set();
|
|
69
|
+
for (const issue of issues) {
|
|
70
|
+
const first = issue.path?.[0];
|
|
71
|
+
const key = typeof first === 'string'
|
|
72
|
+
? first
|
|
73
|
+
: typeof first?.key === 'string'
|
|
74
|
+
? (first.key)
|
|
75
|
+
: undefined;
|
|
76
|
+
// Only report fields the node actually collected. An issue on a field that was never
|
|
77
|
+
// supplied is a missing-field problem, which computeMissingFields already owns.
|
|
78
|
+
if (key && key in data)
|
|
79
|
+
fields.add(key);
|
|
80
|
+
}
|
|
81
|
+
return [...fields];
|
|
82
|
+
}
|
|
36
83
|
export function schemaSatisfied(node, state) {
|
|
37
84
|
const data = getCollectData(state, node.id);
|
|
38
|
-
|
|
85
|
+
if (computeMissingFields(node, data).length > 0)
|
|
86
|
+
return false;
|
|
87
|
+
return invalidCollectFields(node, data).length === 0;
|
|
39
88
|
}
|
|
40
89
|
export function projectCollectData(node, state) {
|
|
41
90
|
// Hand onComplete EVERY field the node collected (all schema keys present in
|
|
@@ -156,7 +205,7 @@ function fieldPopulated(value) {
|
|
|
156
205
|
}
|
|
157
206
|
return true;
|
|
158
207
|
}
|
|
159
|
-
function inferRequiredFields(schema) {
|
|
208
|
+
export function inferRequiredFields(schema) {
|
|
160
209
|
const zodSchema = schema;
|
|
161
210
|
if (typeof zodSchema?.shape === 'object') {
|
|
162
211
|
return Object.keys(zodSchema.shape);
|
package/dist/flow/runFlow.d.ts
CHANGED
|
@@ -16,4 +16,15 @@ export type FlowResult = {
|
|
|
16
16
|
export declare class FlowOscillationError extends Error {
|
|
17
17
|
constructor(from: string, to: string);
|
|
18
18
|
}
|
|
19
|
+
/**
|
|
20
|
+
* Drop everything a previous run of `flow` collected. Namespaced cache keys AND the
|
|
21
|
+
* un-namespaced copies `reduceTransition` promotes onto `run.state` via Object.assign.
|
|
22
|
+
*
|
|
23
|
+
* Clearing only the cache was not enough. The promoted fields are a plain merge, and
|
|
24
|
+
* `projectCollectData` omits any field the new extraction did not supply — so an optional
|
|
25
|
+
* field answered on report #1 (say `accessNotes: "key under the mat"`) and left blank on
|
|
26
|
+
* report #2 survives the merge and is read by the next action node as if it belonged to
|
|
27
|
+
* report #2. A work order then ships another unit's access instructions.
|
|
28
|
+
*/
|
|
29
|
+
export declare function clearFlowCollectCache(state: Record<string, unknown>, flow: Flow): void;
|
|
19
30
|
export declare function runFlow(flow: Flow, run: RunState, driver: ChannelDriver, ctx: RunContext, agent?: AgentConfig): Promise<FlowResult>;
|
package/dist/flow/runFlow.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { popFlowPark, runCollectDigression } from './collectDigression.js';
|
|
2
2
|
import { parseConfirmation } from './confirmParse.js';
|
|
3
|
+
import { inferRequiredFields, resetCollect } from './extraction.js';
|
|
3
4
|
import { hasPendingUserInput, setPendingUserInput } from '../runtime/channels/inputBuffer.js';
|
|
4
5
|
import { userInputToText } from '../runtime/userInput.js';
|
|
5
6
|
import { collectUntilComplete } from './collectUntilComplete.js';
|
|
@@ -11,7 +12,7 @@ import { evaluateReplyControl } from './controlEvaluator.js';
|
|
|
11
12
|
import { runNodeVerify, VerifyBlockedError } from './verify.js';
|
|
12
13
|
import { loadRecordedSteps } from '../runtime/durable/replay.js';
|
|
13
14
|
import { persistTurnUsageFromTurn } from '../runtime/turnTokenUsage.js';
|
|
14
|
-
import { isApprovalDenial, isControlFlowSignal } from '../runtime/controlFlowSignal.js';
|
|
15
|
+
import { isApprovalDenial, isControlFlowSignal, isRecoverableToolError } from '../runtime/controlFlowSignal.js';
|
|
15
16
|
import { emitInteractiveOnNodeEnter } from './emitInteractive.js';
|
|
16
17
|
import { appendConversationAudit } from '../audit/record.js';
|
|
17
18
|
import { appendSafeAssistantMessage, degradeFlowError, findEscalateNode, } from './degrade.js';
|
|
@@ -253,6 +254,27 @@ async function dispatchNode(node, run, driver, ctx, agent, flow) {
|
|
|
253
254
|
}
|
|
254
255
|
throw new Error(`Unknown node kind: ${node.kind}`);
|
|
255
256
|
}
|
|
257
|
+
/**
|
|
258
|
+
* Drop everything a previous run of `flow` collected. Namespaced cache keys AND the
|
|
259
|
+
* un-namespaced copies `reduceTransition` promotes onto `run.state` via Object.assign.
|
|
260
|
+
*
|
|
261
|
+
* Clearing only the cache was not enough. The promoted fields are a plain merge, and
|
|
262
|
+
* `projectCollectData` omits any field the new extraction did not supply — so an optional
|
|
263
|
+
* field answered on report #1 (say `accessNotes: "key under the mat"`) and left blank on
|
|
264
|
+
* report #2 survives the merge and is read by the next action node as if it belonged to
|
|
265
|
+
* report #2. A work order then ships another unit's access instructions.
|
|
266
|
+
*/
|
|
267
|
+
export function clearFlowCollectCache(state, flow) {
|
|
268
|
+
for (const node of flow.nodes) {
|
|
269
|
+
if (node.kind !== 'collect')
|
|
270
|
+
continue;
|
|
271
|
+
delete state[`__collect_${node.id}`];
|
|
272
|
+
delete state[`__collectTurns_${node.id}`];
|
|
273
|
+
for (const field of inferRequiredFields(node.schema)) {
|
|
274
|
+
delete state[field];
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
256
278
|
export async function runFlow(flow, run, driver, ctx, agent) {
|
|
257
279
|
const registry = buildNodeRegistry(flow);
|
|
258
280
|
const startNode = resolveStartNode(flow);
|
|
@@ -262,6 +284,18 @@ export async function runFlow(flow, run, driver, ctx, agent) {
|
|
|
262
284
|
throw new Error(`Unknown active node "${initialNodeId}" in flow "${flow.name}"`);
|
|
263
285
|
}
|
|
264
286
|
if (!run.activeNode) {
|
|
287
|
+
// Fresh entry, not a resume. A collect node caches its extraction under
|
|
288
|
+
// `__collect_<nodeId>` and that cache is SUPPOSED to survive turn boundaries — it is how
|
|
289
|
+
// fields accumulate across several user turns mid-flow. But it must not survive the flow
|
|
290
|
+
// itself: re-entering a completed flow found the previous run's cache already complete,
|
|
291
|
+
// finished instantly with those values, and the action node acted on them.
|
|
292
|
+
//
|
|
293
|
+
// Observed live: three maintenance reports for three different units produced three
|
|
294
|
+
// copies of the FIRST work order; the units actually reported were never touched.
|
|
295
|
+
//
|
|
296
|
+
// Cleared here rather than on completion so mid-flow accumulation is untouched — which
|
|
297
|
+
// is what `continuity.test.ts` and the G14 slot-correction test encode.
|
|
298
|
+
clearFlowCollectCache(run.state, flow);
|
|
265
299
|
run.activeNode = node.id;
|
|
266
300
|
run.activeFlow = flow.name;
|
|
267
301
|
ctx.emit({ channel: 'internal', type: 'flow-enter', payload: { flow: flow.name } });
|
|
@@ -270,7 +304,15 @@ export async function runFlow(flow, run, driver, ctx, agent) {
|
|
|
270
304
|
}
|
|
271
305
|
const edgeCounts = new Map();
|
|
272
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;
|
|
273
312
|
for (;;) {
|
|
313
|
+
if (isCollectNode(node)) {
|
|
314
|
+
lastCollectNode = node;
|
|
315
|
+
}
|
|
274
316
|
let transition;
|
|
275
317
|
try {
|
|
276
318
|
transition = await dispatchNode(node, run, driver, ctx, agent, flow);
|
|
@@ -282,6 +324,25 @@ export async function runFlow(flow, run, driver, ctx, agent) {
|
|
|
282
324
|
if (isControlFlowSignal(error) || isApprovalDenial(error)) {
|
|
283
325
|
throw error;
|
|
284
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
|
+
}
|
|
285
346
|
const message = error instanceof Error ? error.message : String(error);
|
|
286
347
|
ctx.emit({ channel: 'client', type: 'error', payload: { error: message } });
|
|
287
348
|
return degradeFlowError(flow, registry, run, driver, ctx, (n, r, d, c) => dispatchNode(n, r, d, c, agent, flow));
|
|
@@ -21,6 +21,19 @@ import type { WakeOptions } from '../scheduler/index.js';
|
|
|
21
21
|
import type { HandoffInputFilter } from './handoffFilters.js';
|
|
22
22
|
import type { AgentSpan, AgentTrace } from '../types/trace.js';
|
|
23
23
|
import { type TraceSink, type TraceStore } from '../tracing/TraceStore.js';
|
|
24
|
+
/**
|
|
25
|
+
* What the user is told when the run hands off to a human and the app has not configured an
|
|
26
|
+
* escalation handler to say something better. Silence is the wrong default: an escalation
|
|
27
|
+
* that emits no text is indistinguishable from the agent having crashed.
|
|
28
|
+
*/
|
|
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.";
|
|
24
37
|
export interface TracingConfig {
|
|
25
38
|
enabled?: boolean;
|
|
26
39
|
store?: TraceStore;
|
package/dist/runtime/Runtime.js
CHANGED
|
@@ -35,6 +35,19 @@ import { MemoryTraceStore } from '../tracing/MemoryTraceStore.js';
|
|
|
35
35
|
import { mutateSessionWithRetry } from '../session/utils.js';
|
|
36
36
|
import { isTraceStore } from '../tracing/TraceStore.js';
|
|
37
37
|
import { runHookSafely } from './runHookSafely.js';
|
|
38
|
+
/**
|
|
39
|
+
* What the user is told when the run hands off to a human and the app has not configured an
|
|
40
|
+
* escalation handler to say something better. Silence is the wrong default: an escalation
|
|
41
|
+
* that emits no text is indistinguishable from the agent having crashed.
|
|
42
|
+
*/
|
|
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.";
|
|
38
51
|
export class Runtime {
|
|
39
52
|
config;
|
|
40
53
|
agentsById;
|
|
@@ -86,7 +99,23 @@ export class Runtime {
|
|
|
86
99
|
}
|
|
87
100
|
const execute = async () => {
|
|
88
101
|
let runCtx;
|
|
102
|
+
// Whether the user has been told anything at all this turn. A terminal handoff with
|
|
103
|
+
// no escalation handler configured used to emit zero text — seven consecutive turns
|
|
104
|
+
// were observed producing empty output while silently firing transfer_to_agent, which
|
|
105
|
+
// is indistinguishable from an outage.
|
|
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();
|
|
89
114
|
const emit = (part) => {
|
|
115
|
+
if (part.type === 'text-delta' && part.payload.delta)
|
|
116
|
+
sawUserText = true;
|
|
117
|
+
if (part.type === 'handoff')
|
|
118
|
+
announcedHandoffs.add(part.payload.targetAgent);
|
|
90
119
|
recorder?.record(part);
|
|
91
120
|
if (part.type === 'done')
|
|
92
121
|
this.flushTraceSinks();
|
|
@@ -142,6 +171,13 @@ export class Runtime {
|
|
|
142
171
|
// THIS turn's consumption (delta), not the running session total (see the
|
|
143
172
|
// per-turn scope requirement in the observability guide).
|
|
144
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;
|
|
145
181
|
const model = opened.agent.model ?? this.defaultModel;
|
|
146
182
|
if (!model) {
|
|
147
183
|
throw new Error('Runtime requires agent.model or config.defaultModel');
|
|
@@ -191,152 +227,213 @@ export class Runtime {
|
|
|
191
227
|
let terminalOutcome;
|
|
192
228
|
let overflowRetried = false;
|
|
193
229
|
try {
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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),
|
|
219
259
|
});
|
|
220
|
-
runCtx.runState.status = 'paused';
|
|
221
|
-
await runCtx.runStore.putRunState(runCtx.runState);
|
|
222
|
-
await this.dispatchEscalation(runCtx, activeAgent, { reason: loopResult.reason ?? 'handoff_to_human', category: loopResult.category }, emit, { setLatch: false });
|
|
223
|
-
break;
|
|
224
|
-
}
|
|
225
|
-
handoffCount += 1;
|
|
226
|
-
if (handoffCount > this.maxHandoffs) {
|
|
227
|
-
throw new Error(`maxHandoffs exceeded (${this.maxHandoffs})`);
|
|
228
|
-
}
|
|
229
|
-
// Cross-turn ping-pong safeguard (handoffCount resets each turn, so it
|
|
230
|
-
// can't catch A↔B oscillation spread across turns). This is a bound
|
|
231
|
-
// ABOVE maxHandoffs: within-run runaway is caught by the maxHandoffs
|
|
232
|
-
// check above; oscillation only fires for same-pair accumulation in the
|
|
233
|
-
// persisted handoffHistory beyond that, so it never pre-empts maxHandoffs.
|
|
234
|
-
if (isHandoffOscillating(opened.session.handoffHistory, runCtx.runState.activeAgentId, loopResult.to, this.maxHandoffs + 1)) {
|
|
235
|
-
throw new Error(`Handoff oscillation detected between "${runCtx.runState.activeAgentId}" and "${loopResult.to}"`);
|
|
236
260
|
}
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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;
|
|
240
268
|
}
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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(),
|
|
342
|
+
});
|
|
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,
|
|
353
|
+
});
|
|
354
|
+
runCtx.runState.messages = filtered.messages;
|
|
355
|
+
runCtx.session.workingMemory = filtered.workingMemory;
|
|
356
|
+
}
|
|
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,
|
|
257
363
|
});
|
|
258
|
-
runCtx.
|
|
259
|
-
|
|
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
|
+
},
|
|
413
|
+
});
|
|
414
|
+
await runCtx.runStore.putRunState(runCtx.runState);
|
|
415
|
+
continue;
|
|
260
416
|
}
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
configTools: this.config.tools,
|
|
265
|
-
knowledgeProvider,
|
|
266
|
-
defaultWorkingMemoryStore: this.config.defaultWorkingMemoryStore,
|
|
267
|
-
});
|
|
268
|
-
runCtx.autoRetrieve = knowledgeProvider
|
|
269
|
-
? buildAutoRetrieveProvider(knowledgeProvider, target)
|
|
270
|
-
: undefined;
|
|
271
|
-
runCtx.globalTools = targetSurface.globalTools;
|
|
272
|
-
runCtx.skillPrompt = targetSurface.skillPrompt;
|
|
273
|
-
runCtx.workingMemoryPrompt = appendGoalsPrompt(targetSurface.workingMemoryPrompt, runCtx.session.workingMemory);
|
|
274
|
-
runCtx.workingMemoryTools = targetSurface.workingMemoryTools;
|
|
275
|
-
runCtx.fs = targetSurface.resolvedWorkspace?.fs;
|
|
276
|
-
runCtx.memoryService = this.config.memoryService
|
|
277
|
-
? buildMemoryService(this.config.memoryService, target)
|
|
278
|
-
: undefined;
|
|
279
|
-
const targetPolicies = resolveAgentPolicies(target);
|
|
280
|
-
const targetModel = target.model ?? this.defaultModel;
|
|
281
|
-
if (!targetModel) {
|
|
282
|
-
throw new Error('Runtime requires agent.model or config.defaultModel');
|
|
417
|
+
if (loopResult.kind === 'ended') {
|
|
418
|
+
terminalOutcome = 'resolved';
|
|
419
|
+
break;
|
|
283
420
|
}
|
|
284
|
-
|
|
285
|
-
(
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
runCtx.validationPolicies = targetPolicies.validationPolicies;
|
|
294
|
-
runCtx.inputProcessors = targetPolicies.inputProcessors;
|
|
295
|
-
runCtx.outputProcessors = targetPolicies.outputProcessors;
|
|
296
|
-
runCtx.toolExecutor = new CoreToolExecutor({
|
|
297
|
-
tools: targetSurface.executorTools,
|
|
298
|
-
enforcer: targetPolicies.enforcer,
|
|
299
|
-
agentId: target.id,
|
|
300
|
-
onInterim: (message) => {
|
|
301
|
-
const id = crypto.randomUUID();
|
|
302
|
-
emit({ channel: 'client', type: 'text-start', payload: { id } });
|
|
303
|
-
emit({
|
|
304
|
-
channel: 'client',
|
|
305
|
-
type: 'text-delta',
|
|
306
|
-
payload: { id, delta: message },
|
|
307
|
-
});
|
|
308
|
-
emit({ channel: 'client', type: 'text-end', payload: { id } });
|
|
309
|
-
},
|
|
310
|
-
onChunk: (chunk, toolName, toolCallId) => {
|
|
311
|
-
emit({
|
|
312
|
-
channel: 'internal',
|
|
313
|
-
type: 'tool-result',
|
|
314
|
-
payload: { toolName, result: chunk, toolCallId, preliminary: true },
|
|
315
|
-
});
|
|
316
|
-
},
|
|
317
|
-
});
|
|
318
|
-
await runCtx.runStore.putRunState(runCtx.runState);
|
|
319
|
-
continue;
|
|
320
|
-
}
|
|
321
|
-
if (loopResult.kind === 'ended') {
|
|
322
|
-
terminalOutcome = 'resolved';
|
|
323
|
-
break;
|
|
324
|
-
}
|
|
325
|
-
if (loopResult.kind === 'paused') {
|
|
326
|
-
if (runCtx.runState.waitingFor?.signalName === '__escalate') {
|
|
327
|
-
// Flow escalate() parks on the durable signal — notify the human
|
|
328
|
-
// side now; the latch keeps the post-resume terminal handoff from
|
|
329
|
-
// notifying a second time.
|
|
330
|
-
const meta = runCtx.runState.waitingFor.meta;
|
|
331
|
-
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;
|
|
332
430
|
}
|
|
333
431
|
break;
|
|
334
432
|
}
|
|
335
|
-
|
|
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);
|
|
336
436
|
}
|
|
337
|
-
// Post-turn maintenance: text already streamed, so the summarizer call
|
|
338
|
-
// is off the user's latency path; the NEXT turn starts compact.
|
|
339
|
-
await this.applyCompaction(runCtx, activeAgent, emit, false);
|
|
340
437
|
}
|
|
341
438
|
catch (error) {
|
|
342
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 };
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { isControlFlowSignal } from '../../controlFlowSignal.js';
|
|
2
3
|
import { SentenceAggregator } from './SentenceAggregator.js';
|
|
3
4
|
function emitMessage(ctx, id, text) {
|
|
4
5
|
ctx.emit({ channel: 'client', type: 'text-start', payload: { id } });
|
|
@@ -31,6 +32,12 @@ export async function speakGated(args) {
|
|
|
31
32
|
}
|
|
32
33
|
}
|
|
33
34
|
catch (err) {
|
|
35
|
+
// A suspend unwinds through here on its way to the host loop. It is not a failure and
|
|
36
|
+
// must not reach the user as one — the same rule executeModelToolCall and runFlow
|
|
37
|
+
// already apply. This third path was missed, so an approval pause still surfaced as
|
|
38
|
+
// "error: Run suspended waiting for __approval" mid-conversation.
|
|
39
|
+
if (isControlFlowSignal(err))
|
|
40
|
+
throw err;
|
|
34
41
|
const message = err instanceof Error ? err.message : String(err);
|
|
35
42
|
ctx.emit({ channel: 'client', type: 'error', payload: { error: message } });
|
|
36
43
|
throw err instanceof Error ? err : new Error(message);
|
|
@@ -101,6 +108,8 @@ export async function speakGated(args) {
|
|
|
101
108
|
return { text: emitted };
|
|
102
109
|
}
|
|
103
110
|
catch (err) {
|
|
111
|
+
if (isControlFlowSignal(err))
|
|
112
|
+
throw err;
|
|
104
113
|
const message = err instanceof Error ? err.message : String(err);
|
|
105
114
|
ctx.emit({ channel: 'client', type: 'error', payload: { error: message } });
|
|
106
115
|
if (started) {
|
package/dist/runtime/closeRun.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/runtime/hostLoop.js
CHANGED
|
@@ -80,10 +80,19 @@ async function runActiveFlow(flow, run, driver, ctx, agent) {
|
|
|
80
80
|
await ctx.runStore.putRunState(run);
|
|
81
81
|
return { kind: 'turnComplete' };
|
|
82
82
|
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
83
|
+
// A flow that ended because a node threw is NOT completed. Marking it so made the
|
|
84
|
+
// failure permanent for the turn: `select` and the host-control tools both exclude a
|
|
85
|
+
// completed flow from re-entry, so a failed intake could not be retried even though the
|
|
86
|
+
// error was recoverable (a mistyped unit id, a transient tool fault). Observed live —
|
|
87
|
+
// a session held __completedFlows: ["raise_work_order"] with an errored journal step
|
|
88
|
+
// and no work order to show for it.
|
|
89
|
+
const degraded = result.kind === 'ended' && result.reason === 'error_degraded';
|
|
90
|
+
if (!degraded) {
|
|
91
|
+
const completed = run.state.__completedFlows;
|
|
92
|
+
const completedFlows = Array.isArray(completed) ? completed : [];
|
|
93
|
+
if (!completedFlows.includes(flow.name)) {
|
|
94
|
+
run.state.__completedFlows = [...completedFlows, flow.name];
|
|
95
|
+
}
|
|
87
96
|
}
|
|
88
97
|
run.activeFlow = undefined;
|
|
89
98
|
run.activeNode = undefined;
|
|
@@ -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;
|
|
@@ -158,7 +158,8 @@ export function createFsTool(opts) {
|
|
|
158
158
|
'grep for exact terms, names, codes, or keywords (returns matching lines only), ' +
|
|
159
159
|
'cat/read for full file contents. Prefer grep over semantic knowledge search when ' +
|
|
160
160
|
'the user mentions an exact term or identifier — it is faster, cheaper, and exact. ' +
|
|
161
|
-
'Ops: ls, cat, grep, find, read, write, edit.'
|
|
161
|
+
'Ops: ls, cat, grep, find, read, write, edit. grep is CASE-SENSITIVE by default — pass ' +
|
|
162
|
+
'flags:"i" when searching prose, or a heading like "Entry notice" will not match "entry notice".',
|
|
162
163
|
timeoutMs,
|
|
163
164
|
input: workspaceInput,
|
|
164
165
|
execute: async (args) => {
|
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.
|
|
9
|
+
"version": "0.16.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.
|
|
106
|
+
"@kuralle-agents/fs": "0.14.2"
|
|
107
107
|
},
|
|
108
108
|
"dependencies": {
|
|
109
109
|
"chrono-node": "^2.6.0"
|