@kuralle-agents/core 0.14.0 → 0.15.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.
@@ -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
- const data = projectCollectData(node, run.state);
54
- return normalizeTransition(await node.onComplete(data, run.state));
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[];
@@ -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
- return computeMissingFields(node, data).length === 0;
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);
@@ -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>;
@@ -1,5 +1,6 @@
1
1
  import { popFlowPark, runCollectDigression } from './collectDigression.js';
2
2
  import { parseConfirmation } from './confirmParse.js';
3
+ import { inferRequiredFields } 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';
@@ -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 } });
@@ -21,6 +21,12 @@ 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.";
24
30
  export interface TracingConfig {
25
31
  enabled?: boolean;
26
32
  store?: TraceStore;
@@ -35,6 +35,12 @@ 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.";
38
44
  export class Runtime {
39
45
  config;
40
46
  agentsById;
@@ -86,7 +92,14 @@ export class Runtime {
86
92
  }
87
93
  const execute = async () => {
88
94
  let runCtx;
95
+ // Whether the user has been told anything at all this turn. A terminal handoff with
96
+ // no escalation handler configured used to emit zero text — seven consecutive turns
97
+ // were observed producing empty output while silently firing transfer_to_agent, which
98
+ // is indistinguishable from an outage.
99
+ let sawUserText = false;
89
100
  const emit = (part) => {
101
+ if (part.type === 'text-delta' && part.payload.delta)
102
+ sawUserText = true;
90
103
  recorder?.record(part);
91
104
  if (part.type === 'done')
92
105
  this.flushTraceSinks();
@@ -212,12 +225,38 @@ export class Runtime {
212
225
  }
213
226
  if (loopResult.kind === 'handoff') {
214
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.
215
237
  emit({
216
238
  channel: 'internal',
217
239
  type: 'handoff',
218
240
  payload: { targetAgent: loopResult.to, reason: loopResult.reason },
219
241
  });
220
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 },
253
+ });
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
+ ];
259
+ }
221
260
  await runCtx.runStore.putRunState(runCtx.runState);
222
261
  await this.dispatchEscalation(runCtx, activeAgent, { reason: loopResult.reason ?? 'handoff_to_human', category: loopResult.category }, emit, { setLatch: false });
223
262
  break;
@@ -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) {
@@ -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
- const completed = run.state.__completedFlows;
84
- const completedFlows = Array.isArray(completed) ? completed : [];
85
- if (!completedFlows.includes(flow.name)) {
86
- run.state.__completedFlows = [...completedFlows, flow.name];
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;
@@ -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.14.0",
9
+ "version": "0.15.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.0"
106
+ "@kuralle-agents/fs": "0.14.1"
107
107
  },
108
108
  "dependencies": {
109
109
  "chrono-node": "^2.6.0"