@kuralle-agents/core 0.11.0 → 0.13.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.
Files changed (99) hide show
  1. package/README.md +85 -0
  2. package/dist/capabilities/LivePromptAssembler.js +1 -0
  3. package/dist/flow/collectDigression.d.ts +5 -2
  4. package/dist/flow/collectDigression.js +48 -10
  5. package/dist/flow/collectUntilComplete.js +9 -1
  6. package/dist/flow/extraction.d.ts +1 -0
  7. package/dist/flow/extraction.js +4 -0
  8. package/dist/flow/runFlow.js +63 -5
  9. package/dist/index.d.ts +9 -1
  10. package/dist/index.js +5 -0
  11. package/dist/prompts/PromptBuilder.js +7 -0
  12. package/dist/prompts/types.d.ts +2 -0
  13. package/dist/runtime/InMemoryRetrievalCache.d.ts +25 -0
  14. package/dist/runtime/InMemoryRetrievalCache.js +59 -0
  15. package/dist/runtime/KnowledgeProvider.js +6 -1
  16. package/dist/runtime/Runtime.d.ts +44 -0
  17. package/dist/runtime/Runtime.js +196 -13
  18. package/dist/runtime/TokenAccumulator.d.ts +7 -0
  19. package/dist/runtime/TokenAccumulator.js +7 -0
  20. package/dist/runtime/TraceRecorder.d.ts +33 -0
  21. package/dist/runtime/TraceRecorder.js +290 -0
  22. package/dist/runtime/buildAgentToolSurface.js +4 -0
  23. package/dist/runtime/channels/TextDriver.js +45 -22
  24. package/dist/runtime/channels/executeModelTool.d.ts +8 -1
  25. package/dist/runtime/channels/executeModelTool.js +70 -1
  26. package/dist/runtime/channels/inputBuffer.d.ts +1 -0
  27. package/dist/runtime/channels/inputBuffer.js +9 -0
  28. package/dist/runtime/closeRun.js +11 -8
  29. package/dist/runtime/compaction.d.ts +2 -0
  30. package/dist/runtime/compaction.js +3 -2
  31. package/dist/runtime/ctx.js +114 -36
  32. package/dist/runtime/durable/RunStore.d.ts +16 -0
  33. package/dist/runtime/durable/RunStore.js +6 -0
  34. package/dist/runtime/durable/SessionRunStore.d.ts +7 -2
  35. package/dist/runtime/durable/SessionRunStore.js +136 -34
  36. package/dist/runtime/durable/idempotency.d.ts +1 -0
  37. package/dist/runtime/durable/idempotency.js +3 -0
  38. package/dist/runtime/durable/replay.js +7 -2
  39. package/dist/runtime/durable/types.d.ts +11 -0
  40. package/dist/runtime/goals.d.ts +18 -0
  41. package/dist/runtime/goals.js +158 -0
  42. package/dist/runtime/grounding/knowledge.js +1 -1
  43. package/dist/runtime/handoffContinuation.d.ts +20 -0
  44. package/dist/runtime/handoffContinuation.js +30 -0
  45. package/dist/runtime/handoffOscillation.d.ts +6 -0
  46. package/dist/runtime/handoffOscillation.js +17 -0
  47. package/dist/runtime/hostLoop.js +11 -0
  48. package/dist/runtime/index.d.ts +3 -1
  49. package/dist/runtime/index.js +1 -0
  50. package/dist/runtime/openRun.d.ts +2 -0
  51. package/dist/runtime/openRun.js +46 -17
  52. package/dist/runtime/policies/limits.d.ts +1 -0
  53. package/dist/runtime/policies/limits.js +3 -0
  54. package/dist/runtime/resolveAgentWorkspace.d.ts +3 -0
  55. package/dist/runtime/resolveAgentWorkspace.js +6 -2
  56. package/dist/runtime/select.d.ts +5 -1
  57. package/dist/runtime/select.js +30 -1
  58. package/dist/runtime/turnTokenUsage.d.ts +28 -0
  59. package/dist/runtime/turnTokenUsage.js +70 -0
  60. package/dist/session/SessionStore.d.ts +6 -0
  61. package/dist/session/SessionStore.js +12 -1
  62. package/dist/session/stores/MemoryStore.d.ts +1 -1
  63. package/dist/session/stores/MemoryStore.js +16 -2
  64. package/dist/session/testing.d.ts +6 -1
  65. package/dist/session/testing.js +34 -0
  66. package/dist/session/utils.d.ts +3 -0
  67. package/dist/session/utils.js +23 -0
  68. package/dist/tools/effect/ToolExecutor.js +10 -2
  69. package/dist/tools/effect/defineTool.d.ts +3 -0
  70. package/dist/tools/effect/defineTool.js +3 -0
  71. package/dist/tools/fs/caps.d.ts +21 -0
  72. package/dist/tools/fs/caps.js +59 -0
  73. package/dist/tools/fs/createFsTool.js +68 -7
  74. package/dist/tools/fs/createShellTool.d.ts +7 -0
  75. package/dist/tools/fs/createShellTool.js +90 -0
  76. package/dist/tracing/MemoryTraceStore.d.ts +16 -0
  77. package/dist/tracing/MemoryTraceStore.js +56 -0
  78. package/dist/tracing/OtelTraceSink.d.ts +127 -0
  79. package/dist/tracing/OtelTraceSink.js +101 -0
  80. package/dist/tracing/TraceStore.d.ts +19 -0
  81. package/dist/tracing/TraceStore.js +32 -0
  82. package/dist/tracing/index.d.ts +3 -0
  83. package/dist/tracing/index.js +3 -0
  84. package/dist/tracing/testing.d.ts +3 -0
  85. package/dist/tracing/testing.js +45 -0
  86. package/dist/types/agentConfig.d.ts +4 -1
  87. package/dist/types/channel.d.ts +14 -1
  88. package/dist/types/effectTool.d.ts +6 -0
  89. package/dist/types/index.d.ts +1 -0
  90. package/dist/types/index.js +1 -0
  91. package/dist/types/run-context.d.ts +15 -2
  92. package/dist/types/session.d.ts +10 -0
  93. package/dist/types/shell.d.ts +15 -0
  94. package/dist/types/shell.js +1 -0
  95. package/dist/types/stream.d.ts +8 -0
  96. package/dist/types/trace.d.ts +50 -0
  97. package/dist/types/trace.js +1 -0
  98. package/guides/EXAMPLE_VERIFICATION.md +4 -4
  99. package/package.json +14 -4
@@ -0,0 +1,158 @@
1
+ import { generateObject } from 'ai';
2
+ import { z } from 'zod';
3
+ export const GOALS_KEY = '__goals';
4
+ const goalPatchSchema = z.object({
5
+ add: z
6
+ .array(z.object({
7
+ topic: z.string(),
8
+ note: z.union([z.string(), z.null()]),
9
+ }))
10
+ .default([]),
11
+ resolve: z.array(z.string()).default([]),
12
+ });
13
+ function isTrackedGoal(value) {
14
+ if (!value || typeof value !== 'object') {
15
+ return false;
16
+ }
17
+ const goal = value;
18
+ return (typeof goal.topic === 'string' &&
19
+ (goal.status === 'open' || goal.status === 'resolved') &&
20
+ typeof goal.lastTurn === 'number');
21
+ }
22
+ function normalizeTopic(topic) {
23
+ return topic.trim().toLowerCase();
24
+ }
25
+ export function getGoals(state) {
26
+ const raw = state[GOALS_KEY];
27
+ if (!Array.isArray(raw)) {
28
+ return [];
29
+ }
30
+ return raw.filter(isTrackedGoal);
31
+ }
32
+ export function addGoal(state, topic, lastTurn = 0, note) {
33
+ const trimmed = topic.trim();
34
+ if (!trimmed) {
35
+ return state;
36
+ }
37
+ const goals = getGoals(state);
38
+ const key = normalizeTopic(trimmed);
39
+ const existingIndex = goals.findIndex((goal) => normalizeTopic(goal.topic) === key);
40
+ if (existingIndex >= 0) {
41
+ const next = [...goals];
42
+ const existing = next[existingIndex];
43
+ next[existingIndex] = {
44
+ ...existing,
45
+ topic: trimmed,
46
+ status: 'open',
47
+ lastTurn,
48
+ note: note ?? existing.note,
49
+ };
50
+ return { ...state, [GOALS_KEY]: next };
51
+ }
52
+ const entry = { topic: trimmed, status: 'open', lastTurn };
53
+ if (note) {
54
+ entry.note = note;
55
+ }
56
+ return { ...state, [GOALS_KEY]: [...goals, entry] };
57
+ }
58
+ export function resolveGoal(state, topic, lastTurn = 0) {
59
+ const trimmed = topic.trim();
60
+ if (!trimmed) {
61
+ return state;
62
+ }
63
+ const key = normalizeTopic(trimmed);
64
+ const goals = getGoals(state);
65
+ let changed = false;
66
+ const next = goals.map((goal) => {
67
+ if (normalizeTopic(goal.topic) !== key) {
68
+ return goal;
69
+ }
70
+ changed = true;
71
+ return { ...goal, status: 'resolved', lastTurn };
72
+ });
73
+ if (!changed) {
74
+ return state;
75
+ }
76
+ return { ...state, [GOALS_KEY]: next };
77
+ }
78
+ export function listOpenGoals(state) {
79
+ return getGoals(state)
80
+ .filter((goal) => goal.status === 'open')
81
+ .map((goal) => goal.topic);
82
+ }
83
+ export function projectGoalsPrompt(goals) {
84
+ const open = goals.filter((goal) => goal.status === 'open');
85
+ if (open.length === 0) {
86
+ return '';
87
+ }
88
+ const parts = open.map((goal) => goal.note ? `${goal.topic} (${goal.status}, ${goal.note})` : `${goal.topic} (${goal.status})`);
89
+ return `Open threads: ${parts.join('; ')}`;
90
+ }
91
+ export function projectGoalsPromptFromState(state) {
92
+ return projectGoalsPrompt(getGoals(state));
93
+ }
94
+ function readSessionTurn(state) {
95
+ const value = state['__ariaSessionTurn'];
96
+ return typeof value === 'number' && Number.isFinite(value) ? value : 0;
97
+ }
98
+ function latestExchange(messages) {
99
+ let assistant = '';
100
+ let user = '';
101
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
102
+ const message = messages[index];
103
+ if (!assistant && message?.role === 'assistant' && typeof message.content === 'string') {
104
+ assistant = message.content;
105
+ continue;
106
+ }
107
+ if (assistant && message?.role === 'user' && typeof message.content === 'string') {
108
+ user = message.content;
109
+ break;
110
+ }
111
+ }
112
+ if (!user || !assistant) {
113
+ return null;
114
+ }
115
+ return { user, assistant };
116
+ }
117
+ function applyGoalPatch(state, patch) {
118
+ let next = state;
119
+ const turn = readSessionTurn(state);
120
+ for (const topic of patch.resolve) {
121
+ next = resolveGoal(next, topic, turn);
122
+ }
123
+ for (const entry of patch.add) {
124
+ const note = entry.note ?? undefined;
125
+ next = addGoal(next, entry.topic, turn, note);
126
+ }
127
+ return next;
128
+ }
129
+ export async function updateGoalsFromTurn(ctx, model) {
130
+ const exchange = latestExchange(ctx.runState.messages);
131
+ if (!exchange) {
132
+ return;
133
+ }
134
+ const currentGoals = getGoals(ctx.session.workingMemory);
135
+ const goalsSummary = currentGoals.length === 0
136
+ ? 'none'
137
+ : currentGoals
138
+ .map((goal) => goal.note
139
+ ? `${goal.topic} [${goal.status}] — ${goal.note}`
140
+ : `${goal.topic} [${goal.status}]`)
141
+ .join('\n');
142
+ const { object } = await generateObject({
143
+ model,
144
+ schema: goalPatchSchema,
145
+ temperature: 0,
146
+ system: 'You track conversational threads (goals/topics the user may circle back to). ' +
147
+ 'Given the latest exchange and current tracked threads, return only schema fields. ' +
148
+ 'Add a topic when the user opens a new thread or revisits one not yet tracked. ' +
149
+ 'Resolve a topic when the exchange clearly completes or abandons it. ' +
150
+ 'Keep topics short labels (1-4 words). Do not invent threads unrelated to the exchange.',
151
+ prompt: `Current tracked threads:\n${goalsSummary}\n\n` +
152
+ `Latest user message:\n${exchange.user}\n\n` +
153
+ `Latest assistant message:\n${exchange.assistant}\n\n` +
154
+ 'Return add[] for new/reopened open topics and resolve[] for completed topics.',
155
+ });
156
+ const patched = applyGoalPatch(ctx.session.workingMemory, object);
157
+ ctx.session.workingMemory = patched;
158
+ }
@@ -42,7 +42,7 @@ export function buildAutoRetrieveProvider(provider, agent) {
42
42
  retrieve: async (ctx, scope) => {
43
43
  const query = scope?.query ?? latestUserMessage(ctx);
44
44
  const merged = scope?.knowledge ? { ...overrides, ...scope.knowledge } : overrides;
45
- const cache = undefined;
45
+ const cache = ctx.retrievalCache;
46
46
  const { results, events } = await provider.retrieve(query || ' ', cache, merged, false);
47
47
  for (const event of events) {
48
48
  ctx.emit(event);
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Silent handoff: a transfer between agents should read as one continuous
3
+ * assistant to the user. The transfer itself is already a silent control tool
4
+ * call, but the TARGET agent's own instructions may tell it to introduce itself
5
+ * ("Bill here"), which leaks the switch. On a silent handoff we append a strong
6
+ * continuation directive to the target's system prompt so it does not greet or
7
+ * re-introduce — mirroring the OpenAI Agents SDK's `nest_handoff_history`
8
+ * context-note approach. Off (visible handoff) leaves the target's instructions
9
+ * untouched.
10
+ */
11
+ import type { Instructions } from '../types/agentConfig.js';
12
+ export declare const HANDOFF_CONTINUATION_DIRECTIVE: string;
13
+ /**
14
+ * Append the continuation directive to a target agent's instructions on a silent
15
+ * handoff. Handles every `Instructions` form: a string is suffixed; a (sync)
16
+ * function is wrapped so the directive is appended to its resolved text; an
17
+ * `AgentPrompt` object is left untouched (it cannot be safely suffixed, and is
18
+ * rare as a base layer); `undefined` yields the directive alone.
19
+ */
20
+ export declare function applyHandoffContinuation(base: Instructions | undefined): Instructions;
@@ -0,0 +1,30 @@
1
+ export const HANDOFF_CONTINUATION_DIRECTIVE = 'You are seamlessly continuing an ongoing conversation with the same user — this is ' +
2
+ 'not a new conversation. Do NOT greet the user, introduce yourself, state your name ' +
3
+ 'or role, say "hello", or mention any transfer, connection, or that a different ' +
4
+ 'assistant is now helping. Ignore any earlier instruction to open with a greeting or ' +
5
+ 'self-introduction. Continue as one uninterrupted assistant and answer the request directly.';
6
+ const SUFFIX = `\n\n[Handoff continuation]\n${HANDOFF_CONTINUATION_DIRECTIVE}`;
7
+ /**
8
+ * Append the continuation directive to a target agent's instructions on a silent
9
+ * handoff. Handles every `Instructions` form: a string is suffixed; a (sync)
10
+ * function is wrapped so the directive is appended to its resolved text; an
11
+ * `AgentPrompt` object is left untouched (it cannot be safely suffixed, and is
12
+ * rare as a base layer); `undefined` yields the directive alone.
13
+ */
14
+ export function applyHandoffContinuation(base) {
15
+ if (typeof base === 'string') {
16
+ return `${base}${SUFFIX}`;
17
+ }
18
+ if (typeof base === 'function') {
19
+ return (ctx) => {
20
+ const inner = base(ctx);
21
+ if (typeof inner !== 'string') {
22
+ // resolveInstructions only supports sync-string functions; match that contract.
23
+ throw new Error('handoff continuation: instructions function must return a string synchronously');
24
+ }
25
+ return `${inner}${SUFFIX}`;
26
+ };
27
+ }
28
+ // AgentPrompt object → preserve as-is (do not drop the persona); undefined → directive only.
29
+ return base ?? HANDOFF_CONTINUATION_DIRECTIVE;
30
+ }
@@ -0,0 +1,6 @@
1
+ /** True when the recent handoff history shows A↔B oscillation at/above `threshold` hops
2
+ * between the same unordered pair, counting the pending from→to as the latest hop. */
3
+ export declare function isHandoffOscillating(history: Array<{
4
+ from: string;
5
+ to: string;
6
+ }>, pendingFrom: string, pendingTo: string, threshold: number): boolean;
@@ -0,0 +1,17 @@
1
+ /** True when the recent handoff history shows A↔B oscillation at/above `threshold` hops
2
+ * between the same unordered pair, counting the pending from→to as the latest hop. */
3
+ export function isHandoffOscillating(history, pendingFrom, pendingTo, threshold) {
4
+ const pairKey = (a, b) => [a, b].sort().join('\0');
5
+ const target = pairKey(pendingFrom, pendingTo);
6
+ let count = 1; // the pending hop
7
+ for (let i = history.length - 1; i >= 0; i -= 1) {
8
+ const h = history[i];
9
+ if (!h)
10
+ break;
11
+ if (pairKey(h.from, h.to) === target)
12
+ count += 1;
13
+ else
14
+ break; // only CONSECUTIVE same-pair hops count as oscillation
15
+ }
16
+ return count >= threshold;
17
+ }
@@ -9,6 +9,7 @@ import { hasHostControlTargets } from './hostControlTools.js';
9
9
  import { isValidControl, resolveHostControl, startHostControlGuard, } from './hostControlGuard.js';
10
10
  import { resolveDispatchMode, isAdvisoryDispatch } from './dispatchMode.js';
11
11
  import { adaptHostSelect } from './hostClassifyAdapter.js';
12
+ import { persistTurnUsageFromTurn } from './turnTokenUsage.js';
12
13
  export async function hostLoop(options) {
13
14
  const { agent, run, driver, ctx } = options;
14
15
  const classify = options.classify ??
@@ -67,6 +68,12 @@ async function runActiveFlow(flow, run, driver, ctx, agent) {
67
68
  ctx.resetCallsites();
68
69
  const result = await runFlow(flow, run, driver, ctx, agent);
69
70
  if (result.kind === 'handoff') {
71
+ // A handoff fired from inside a flow: the source flow is abandoned for this turn.
72
+ // Clear the active-flow pointers so the target agent does not try (and fail) to
73
+ // resume a flow that belongs to the source agent (G17).
74
+ run.activeFlow = undefined;
75
+ run.activeNode = undefined;
76
+ await ctx.runStore.putRunState(run);
70
77
  return { kind: 'handoff', to: result.to, reason: result.reason };
71
78
  }
72
79
  if (result.kind === 'awaitingUser') {
@@ -111,12 +118,16 @@ async function runFreeConversation(agent, run, driver, ctx, classify) {
111
118
  resolved.hostControl = { dispatchMode, advisoryDispatch };
112
119
  }
113
120
  const turn = await driver.runAgentTurn(resolved, ctx);
121
+ await persistTurnUsageFromTurn(ctx, turn);
114
122
  if (turn.control && isValidControl(turn.control, agent, run)) {
115
123
  emitHostGuardTelemetry(ctx, { invoked: false, reason: 'main-control' });
116
124
  return await executeHostControl(agent, run, driver, ctx, turn.control);
117
125
  }
118
126
  if (turn.text.trim()) {
119
127
  emitHostGuardTelemetry(ctx, { invoked: false, reason: 'answered' });
128
+ if (turn.toolMessages?.length) {
129
+ run.messages = [...run.messages, ...turn.toolMessages];
130
+ }
120
131
  const message = { role: 'assistant', content: turn.text };
121
132
  run.messages = [...run.messages, message];
122
133
  await ctx.runStore.putRunState(run);
@@ -1,5 +1,7 @@
1
- export { createRuntime, Runtime, type HarnessConfig, type RunOptions, } from './Runtime.js';
1
+ export { createRuntime, Runtime, type HarnessConfig, type RunOptions, type TracingConfig, } from './Runtime.js';
2
2
  export type { RuntimeLike } from './RuntimeLike.js';
3
+ export { TraceRecorder, runOnce, type TraceRecorderOptions } from './TraceRecorder.js';
4
+ export type { AgentSpan, AgentTrace, SpanKind } from '../types/trace.js';
3
5
  export { SessionWorkingMemory } from './WorkingMemory.js';
4
6
  export { TextDriver, VoiceDriver } from './channels/index.js';
5
7
  export type { VoiceDriverConfig } from './channels/index.js';
@@ -1,4 +1,5 @@
1
1
  export { createRuntime, Runtime, } from './Runtime.js';
2
+ export { TraceRecorder, runOnce } from './TraceRecorder.js';
2
3
  export { SessionWorkingMemory } from './WorkingMemory.js';
3
4
  export { TextDriver, VoiceDriver } from './channels/index.js';
4
5
  // Pending-input buffer helpers — required by custom ChannelDriver authors to
@@ -21,6 +21,8 @@ export interface OpenRunOptions {
21
21
  seedMessages?: ModelMessage[];
22
22
  historyDelta?: ModelMessage[];
23
23
  signalDelivery?: SignalDelivery;
24
+ /** Stable key for this inbound user message; duplicate webhook retries are ignored (H2). */
25
+ idempotencyKey?: string;
24
26
  transcriptionModel?: TranscriptionModel;
25
27
  defaultAgentId: string;
26
28
  sessionStore: SessionStore;
@@ -3,6 +3,8 @@ import { transcribeAudioParts } from './userInput.js';
3
3
  import { setPendingUserInput } from './channels/inputBuffer.js';
4
4
  import { SessionRunStore } from './durable/SessionRunStore.js';
5
5
  import { recordSignalDelivery } from './durable/replay.js';
6
+ import { resetTurnCount } from './policies/limits.js';
7
+ import { mutateSessionWithRetry } from '../session/utils.js';
6
8
  export function sessionDerivedRunId(sessionId) {
7
9
  return sessionId;
8
10
  }
@@ -26,16 +28,18 @@ export async function openRun(agentsById, options) {
26
28
  };
27
29
  await runStore.initRun(runState);
28
30
  if (initialMessages.length > 0) {
29
- session.messages = [...initialMessages];
30
- await options.sessionStore.save(session);
31
+ await mutateSessionWithRetry(options.sessionStore, session.id, (latest) => {
32
+ latest.messages = [...initialMessages];
33
+ });
31
34
  }
32
35
  }
33
36
  if (options.historyDelta?.length) {
34
37
  runState.messages = [...runState.messages, ...options.historyDelta];
35
38
  runState.updatedAt = Date.now();
36
39
  await runStore.putRunState(runState);
37
- session.messages = [...session.messages, ...options.historyDelta];
38
- await options.sessionStore.save(session);
40
+ await mutateSessionWithRetry(options.sessionStore, session.id, (latest) => {
41
+ latest.messages = [...latest.messages, ...options.historyDelta];
42
+ });
39
43
  }
40
44
  if (options.signalDelivery) {
41
45
  await recordSignalDelivery(runStore, runState, options.signalDelivery);
@@ -53,22 +57,47 @@ export async function openRun(agentsById, options) {
53
57
  const hasInput = typeof effectiveInput === 'string'
54
58
  ? effectiveInput.length > 0
55
59
  : Array.isArray(effectiveInput) && effectiveInput.length > 0;
60
+ const isResume = Boolean(options.signalDelivery);
61
+ const isFlowContinuation = Boolean(runState.activeFlow);
62
+ const isFreshLogicalRun = (hasInput || Boolean(options.wake)) && !isResume && !isFlowContinuation;
63
+ if (isFreshLogicalRun) {
64
+ runState.runEpoch = (runState.runEpoch ?? 0) + 1;
65
+ await runStore.pruneStepsBeforeEpoch(runId, runState.runEpoch);
66
+ resetTurnCount(runState);
67
+ if (Array.isArray(runState.state.__completedFlows)) {
68
+ runState.state.__completedFlows = [];
69
+ }
70
+ runState.updatedAt = Date.now();
71
+ await runStore.putRunState(runState);
72
+ }
56
73
  if (hasInput && effectiveInput !== undefined) {
74
+ if (options.idempotencyKey) {
75
+ const processed = runState.processedInboundKeys ?? [];
76
+ if (processed.includes(options.idempotencyKey)) {
77
+ const agent = agentsById.get(runState.activeAgentId);
78
+ if (!agent) {
79
+ throw new Error(`Unknown activeAgentId "${runState.activeAgentId}"`);
80
+ }
81
+ const latestSession = (await options.sessionStore.get(options.sessionId)) ?? session;
82
+ return { session: latestSession, runState, runStore, agent };
83
+ }
84
+ runState.processedInboundKeys = [...processed, options.idempotencyKey];
85
+ }
57
86
  runState.updatedAt = Date.now();
58
87
  if (runState.activeFlow) {
59
88
  await runStore.putRunState(runState);
60
- const sessionAfterPersist = (await options.sessionStore.get(options.sessionId)) ?? session;
61
- setPendingUserInput(sessionAfterPersist, effectiveInput);
62
- await options.sessionStore.save(sessionAfterPersist);
89
+ await mutateSessionWithRetry(options.sessionStore, session.id, (latest) => {
90
+ setPendingUserInput(latest, effectiveInput);
91
+ });
63
92
  }
64
93
  else {
65
94
  const userMessage = { role: 'user', content: effectiveInput };
66
95
  runState.messages = [...runState.messages, userMessage];
67
96
  runState.updatedAt = Date.now();
68
97
  await runStore.putRunState(runState);
69
- const sessionAfterPersist = (await options.sessionStore.get(options.sessionId)) ?? session;
70
- sessionAfterPersist.messages = [...sessionAfterPersist.messages, userMessage];
71
- await options.sessionStore.save(sessionAfterPersist);
98
+ await mutateSessionWithRetry(options.sessionStore, session.id, (latest) => {
99
+ latest.messages = [...latest.messages, userMessage];
100
+ });
72
101
  }
73
102
  }
74
103
  if (options.wake && !hasInput) {
@@ -84,18 +113,18 @@ export async function openRun(agentsById, options) {
84
113
  runState.messages = [...runState.messages, wakeMessage];
85
114
  runState.updatedAt = Date.now();
86
115
  await runStore.putRunState(runState);
87
- const sessionAfterPersist = (await options.sessionStore.get(options.sessionId)) ?? session;
88
- sessionAfterPersist.messages = [...sessionAfterPersist.messages, wakeMessage];
89
- await options.sessionStore.save(sessionAfterPersist);
116
+ await mutateSessionWithRetry(options.sessionStore, session.id, (latest) => {
117
+ latest.messages = [...latest.messages, wakeMessage];
118
+ });
90
119
  }
91
120
  const agent = agentsById.get(runState.activeAgentId);
92
121
  if (!agent) {
93
122
  throw new Error(`Unknown activeAgentId "${runState.activeAgentId}"`);
94
123
  }
95
- const latestSession = (await options.sessionStore.get(options.sessionId)) ?? session;
96
- latestSession.currentAgent = runState.activeAgentId;
97
- latestSession.activeAgentId = runState.activeAgentId;
98
- await options.sessionStore.save(latestSession);
124
+ const latestSession = await mutateSessionWithRetry(options.sessionStore, session.id, (latest) => {
125
+ latest.currentAgent = runState.activeAgentId;
126
+ latest.activeAgentId = runState.activeAgentId;
127
+ });
99
128
  return { session: latestSession, runState, runStore, agent };
100
129
  }
101
130
  async function loadOrCreateSession(options) {
@@ -4,5 +4,6 @@ export declare class LimitsExceededError extends Error {
4
4
  constructor(message: string);
5
5
  }
6
6
  export declare function incrementTurnCount(run: RunState): number;
7
+ export declare function resetTurnCount(run: RunState): void;
7
8
  export declare function assertWithinTurnLimit(run: RunState, limits?: Limits): void;
8
9
  export declare function resolveMaxSteps(limits: Limits | undefined, fallback: number): number;
@@ -15,6 +15,9 @@ export function incrementTurnCount(run) {
15
15
  run.updatedAt = Date.now();
16
16
  return next;
17
17
  }
18
+ export function resetTurnCount(run) {
19
+ run.state[TURN_COUNT_KEY] = 0;
20
+ }
18
21
  export function assertWithinTurnLimit(run, limits) {
19
22
  const maxTurns = limits?.maxTurns;
20
23
  if (maxTurns == null) {
@@ -1,10 +1,13 @@
1
1
  import type { FileSystem } from '../types/filesystem.js';
2
+ import type { Shell } from '../types/shell.js';
2
3
  export type AgentWorkspaceConfig = FileSystem | {
3
4
  fs: FileSystem;
5
+ shell?: Shell;
4
6
  readOnly?: boolean;
5
7
  };
6
8
  export interface ResolvedAgentWorkspace {
7
9
  fs: FileSystem;
10
+ shell?: Shell;
8
11
  readOnly: boolean;
9
12
  }
10
13
  export declare function resolveAgentWorkspace(workspace: AgentWorkspaceConfig | undefined): ResolvedAgentWorkspace | undefined;
@@ -3,7 +3,11 @@ export function resolveAgentWorkspace(workspace) {
3
3
  return undefined;
4
4
  }
5
5
  if (typeof workspace === 'object' && workspace !== null && 'fs' in workspace) {
6
- return { fs: workspace.fs, readOnly: workspace.readOnly !== false };
6
+ return {
7
+ fs: workspace.fs,
8
+ shell: workspace.shell,
9
+ readOnly: workspace.readOnly !== false,
10
+ };
7
11
  }
8
- return { fs: workspace, readOnly: true };
12
+ return { fs: workspace, readOnly: true, shell: undefined };
9
13
  }
@@ -1,4 +1,4 @@
1
- import type { LanguageModel } from 'ai';
1
+ import type { LanguageModel, ModelMessage } from 'ai';
2
2
  import type { AgentConfig } from '../types/agentConfig.js';
3
3
  import type { Flow } from '../types/flow.js';
4
4
  import type { RunState } from './durable/types.js';
@@ -20,12 +20,15 @@ export interface HostGuardVerdict {
20
20
  reason?: string;
21
21
  confidence?: number;
22
22
  }
23
+ export declare const DEFAULT_CLASSIFIER_CONTEXT_MESSAGES = 6;
23
24
  export interface ClassifyHostOptions {
24
25
  agent: AgentConfig;
25
26
  run: RunState;
26
27
  model: LanguageModel;
27
28
  allowKeep: boolean;
28
29
  excludeFlowNames?: string[];
30
+ /** How many recent messages to include in the classifier prompt. Default: 6. */
31
+ contextMessageLimit?: number;
29
32
  }
30
33
  export declare function classifyHostTarget(options: ClassifyHostOptions): Promise<HostGuardVerdict>;
31
34
  /** @deprecated Use classifyHostTarget — kept as alias for test injection. */
@@ -33,4 +36,5 @@ export declare function selectHostTarget(options: Omit<ClassifyHostOptions, 'all
33
36
  alwaysRoute?: boolean;
34
37
  }): Promise<HostSelection>;
35
38
  export declare function verdictToSelection(verdict: HostGuardVerdict, agent: AgentConfig): HostSelection | undefined;
39
+ export declare function formatRecentConversation(messages: ModelMessage[], limit?: number): string;
36
40
  export { availableHostFlows };
@@ -14,11 +14,13 @@ const dispatcherSchema = z.object({
14
14
  agentId: z.union([z.string(), z.null()]),
15
15
  reason: z.union([z.string(), z.null()]),
16
16
  });
17
+ export const DEFAULT_CLASSIFIER_CONTEXT_MESSAGES = 6;
17
18
  export async function classifyHostTarget(options) {
18
19
  const { agent, run, model, allowKeep } = options;
19
20
  const flows = agent.flows ?? [];
20
21
  const routes = agent.routes ?? [];
21
22
  const latestUser = latestUserMessage(run.messages);
23
+ const recentContext = formatRecentConversation(run.messages, options.contextMessageLimit ?? DEFAULT_CLASSIFIER_CONTEXT_MESSAGES);
22
24
  if (!latestUser) {
23
25
  return { action: 'keep', confidence: 1 };
24
26
  }
@@ -51,7 +53,9 @@ export async function classifyHostTarget(options) {
51
53
  system: 'You are an internal routing classifier. Choose exactly one action. ' +
52
54
  'Output schema fields only — never user-facing prose. ' +
53
55
  'Reason over semantic descriptions only; never match keywords or substrings.',
54
- prompt: `User message:\n${latestUser}\n\n` +
56
+ prompt: (recentContext
57
+ ? `Recent conversation:\n${recentContext}\n\n`
58
+ : `User message:\n${latestUser}\n\n`) +
55
59
  (completedFlows.length > 0 ? `Completed flows: ${completedFlows.join(', ')}\n\n` : '') +
56
60
  (flowLines ? `Available flows:\n${flowLines}\n\n` : '') +
57
61
  (routeLines ? `Routes:\n${routeLines}\n\n` : '') +
@@ -159,6 +163,31 @@ function formatRouteLine(route, index) {
159
163
  const target = route.agent ? `agent "${route.agent}"` : route.flow ? `flow "${route.flow}"` : 'keep';
160
164
  return `- route ${index + 1} → ${target} when: ${route.when}`;
161
165
  }
166
+ function formatMessageContent(message) {
167
+ if (typeof message.content === 'string') {
168
+ return message.content;
169
+ }
170
+ if (Array.isArray(message.content)) {
171
+ return message.content
172
+ .filter((part) => part.type === 'text')
173
+ .map((part) => part.text)
174
+ .join('');
175
+ }
176
+ return '';
177
+ }
178
+ export function formatRecentConversation(messages, limit = DEFAULT_CLASSIFIER_CONTEXT_MESSAGES) {
179
+ const slice = messages.slice(-limit);
180
+ return slice
181
+ .map((message) => {
182
+ const content = formatMessageContent(message);
183
+ if (!content.trim()) {
184
+ return undefined;
185
+ }
186
+ return `${message.role}: ${content}`;
187
+ })
188
+ .filter((line) => line != null)
189
+ .join('\n');
190
+ }
162
191
  function latestUserMessage(messages) {
163
192
  for (let i = messages.length - 1; i >= 0; i -= 1) {
164
193
  const message = messages[i];
@@ -0,0 +1,28 @@
1
+ import type { TurnResult } from '../types/channel.js';
2
+ import type { RunContext } from '../types/run-context.js';
3
+ export declare const TOKEN_USAGE_STATE_KEY = "__tokenUsage";
4
+ export declare const LAST_PROMPT_TOKENS_KEY = "__lastPromptTokens";
5
+ export interface PersistedTokenUsage {
6
+ inputTokens: number;
7
+ outputTokens: number;
8
+ totalTokens: number;
9
+ cacheReadTokens?: number;
10
+ }
11
+ /** Record real turn usage onto run state (survives across turns via persistence). */
12
+ export declare function persistTurnUsageFromTurn(ctx: RunContext, turn: TurnResult): Promise<void>;
13
+ export declare function readLastPromptTokens(state: Record<string, unknown>): number | undefined;
14
+ /** The session-cumulative usage persisted on run state (or undefined before any turn). */
15
+ export declare function readCumulativeUsage(state: Record<string, unknown>): PersistedTokenUsage | undefined;
16
+ export interface TraceTurnUsage {
17
+ /** Input tokens consumed by THIS turn (cumulative delta since the turn opened). */
18
+ inputTokens?: number;
19
+ /** Output tokens generated by THIS turn (cumulative delta since the turn opened). */
20
+ outputTokens?: number;
21
+ /** Context-window occupancy — the last prompt's token count (not a per-turn delta). */
22
+ contextTokens?: number;
23
+ }
24
+ /** Per-turn token usage for a trace's `done` event. `baseline` is the cumulative
25
+ * usage captured when the turn OPENED, so input/output are strictly this turn's
26
+ * consumption (correct for cost attribution — never the running session total).
27
+ * `contextTokens` is the current window occupancy, which is inherently a snapshot. */
28
+ export declare function computeTurnTraceUsage(baseline: PersistedTokenUsage | undefined, state: Record<string, unknown>): TraceTurnUsage;
@@ -0,0 +1,70 @@
1
+ import { TokenAccumulator } from './TokenAccumulator.js';
2
+ export const TOKEN_USAGE_STATE_KEY = '__tokenUsage';
3
+ export const LAST_PROMPT_TOKENS_KEY = '__lastPromptTokens';
4
+ function readPersistedUsage(state) {
5
+ const saved = state[TOKEN_USAGE_STATE_KEY];
6
+ if (!saved || typeof saved !== 'object') {
7
+ return undefined;
8
+ }
9
+ const usage = saved;
10
+ if (typeof usage.inputTokens !== 'number' ||
11
+ typeof usage.outputTokens !== 'number' ||
12
+ typeof usage.totalTokens !== 'number') {
13
+ return undefined;
14
+ }
15
+ return usage;
16
+ }
17
+ function hydrateAccumulator(state) {
18
+ const acc = new TokenAccumulator();
19
+ const saved = readPersistedUsage(state);
20
+ if (saved) {
21
+ acc.restoreCumulative(saved);
22
+ }
23
+ return acc;
24
+ }
25
+ /** Record real turn usage onto run state (survives across turns via persistence). */
26
+ export async function persistTurnUsageFromTurn(ctx, turn) {
27
+ if (!turn.usage) {
28
+ return;
29
+ }
30
+ const acc = hydrateAccumulator(ctx.runState.state);
31
+ const turnIndex = acc.turns.length + 1;
32
+ acc.record({
33
+ turn: turnIndex,
34
+ inputTokens: turn.usage.inputTokens,
35
+ outputTokens: turn.usage.outputTokens,
36
+ totalTokens: turn.usage.totalTokens,
37
+ cacheReadTokens: turn.usage.cacheReadTokens,
38
+ latencyMs: 0,
39
+ });
40
+ ctx.runState.state[TOKEN_USAGE_STATE_KEY] = acc.cumulative;
41
+ ctx.runState.state[LAST_PROMPT_TOKENS_KEY] =
42
+ turn.usage.contextTokens ?? turn.usage.inputTokens;
43
+ ctx.runState.updatedAt = Date.now();
44
+ await ctx.runStore.putRunState(ctx.runState);
45
+ }
46
+ export function readLastPromptTokens(state) {
47
+ const value = state[LAST_PROMPT_TOKENS_KEY];
48
+ return typeof value === 'number' ? value : undefined;
49
+ }
50
+ /** The session-cumulative usage persisted on run state (or undefined before any turn). */
51
+ export function readCumulativeUsage(state) {
52
+ return readPersistedUsage(state);
53
+ }
54
+ /** Per-turn token usage for a trace's `done` event. `baseline` is the cumulative
55
+ * usage captured when the turn OPENED, so input/output are strictly this turn's
56
+ * consumption (correct for cost attribution — never the running session total).
57
+ * `contextTokens` is the current window occupancy, which is inherently a snapshot. */
58
+ export function computeTurnTraceUsage(baseline, state) {
59
+ const contextTokens = readLastPromptTokens(state);
60
+ const now = readPersistedUsage(state);
61
+ if (!now) {
62
+ return contextTokens === undefined ? {} : { contextTokens };
63
+ }
64
+ const base = baseline ?? { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
65
+ return {
66
+ inputTokens: Math.max(0, now.inputTokens - base.inputTokens),
67
+ outputTokens: Math.max(0, now.outputTokens - base.outputTokens),
68
+ ...(contextTokens === undefined ? {} : { contextTokens }),
69
+ };
70
+ }