@kuralle-agents/core 0.12.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 (89) 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 +6 -1
  10. package/dist/index.js +4 -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/channels/TextDriver.js +45 -22
  23. package/dist/runtime/channels/executeModelTool.d.ts +8 -1
  24. package/dist/runtime/channels/executeModelTool.js +70 -1
  25. package/dist/runtime/channels/inputBuffer.d.ts +1 -0
  26. package/dist/runtime/channels/inputBuffer.js +9 -0
  27. package/dist/runtime/closeRun.js +11 -8
  28. package/dist/runtime/compaction.d.ts +2 -0
  29. package/dist/runtime/compaction.js +3 -2
  30. package/dist/runtime/ctx.js +110 -57
  31. package/dist/runtime/durable/RunStore.d.ts +16 -0
  32. package/dist/runtime/durable/RunStore.js +6 -0
  33. package/dist/runtime/durable/SessionRunStore.d.ts +7 -2
  34. package/dist/runtime/durable/SessionRunStore.js +136 -34
  35. package/dist/runtime/durable/idempotency.d.ts +1 -0
  36. package/dist/runtime/durable/idempotency.js +3 -0
  37. package/dist/runtime/durable/replay.js +7 -2
  38. package/dist/runtime/durable/types.d.ts +11 -0
  39. package/dist/runtime/goals.d.ts +18 -0
  40. package/dist/runtime/goals.js +158 -0
  41. package/dist/runtime/grounding/knowledge.js +1 -1
  42. package/dist/runtime/handoffContinuation.d.ts +20 -0
  43. package/dist/runtime/handoffContinuation.js +30 -0
  44. package/dist/runtime/handoffOscillation.d.ts +6 -0
  45. package/dist/runtime/handoffOscillation.js +17 -0
  46. package/dist/runtime/hostLoop.js +11 -0
  47. package/dist/runtime/index.d.ts +3 -1
  48. package/dist/runtime/index.js +1 -0
  49. package/dist/runtime/openRun.d.ts +2 -0
  50. package/dist/runtime/openRun.js +46 -17
  51. package/dist/runtime/policies/limits.d.ts +1 -0
  52. package/dist/runtime/policies/limits.js +3 -0
  53. package/dist/runtime/select.d.ts +5 -1
  54. package/dist/runtime/select.js +30 -1
  55. package/dist/runtime/turnTokenUsage.d.ts +28 -0
  56. package/dist/runtime/turnTokenUsage.js +70 -0
  57. package/dist/session/SessionStore.d.ts +6 -0
  58. package/dist/session/SessionStore.js +12 -1
  59. package/dist/session/stores/MemoryStore.d.ts +1 -1
  60. package/dist/session/stores/MemoryStore.js +16 -2
  61. package/dist/session/testing.d.ts +6 -1
  62. package/dist/session/testing.js +34 -0
  63. package/dist/session/utils.d.ts +3 -0
  64. package/dist/session/utils.js +23 -0
  65. package/dist/tools/effect/ToolExecutor.js +4 -1
  66. package/dist/tools/effect/defineTool.d.ts +2 -0
  67. package/dist/tools/effect/defineTool.js +2 -0
  68. package/dist/tracing/MemoryTraceStore.d.ts +16 -0
  69. package/dist/tracing/MemoryTraceStore.js +56 -0
  70. package/dist/tracing/OtelTraceSink.d.ts +127 -0
  71. package/dist/tracing/OtelTraceSink.js +101 -0
  72. package/dist/tracing/TraceStore.d.ts +19 -0
  73. package/dist/tracing/TraceStore.js +32 -0
  74. package/dist/tracing/index.d.ts +3 -0
  75. package/dist/tracing/index.js +3 -0
  76. package/dist/tracing/testing.d.ts +3 -0
  77. package/dist/tracing/testing.js +45 -0
  78. package/dist/types/agentConfig.d.ts +2 -1
  79. package/dist/types/channel.d.ts +14 -1
  80. package/dist/types/effectTool.d.ts +4 -0
  81. package/dist/types/index.d.ts +1 -0
  82. package/dist/types/index.js +1 -0
  83. package/dist/types/run-context.d.ts +14 -1
  84. package/dist/types/session.d.ts +10 -0
  85. package/dist/types/stream.d.ts +8 -0
  86. package/dist/types/trace.d.ts +50 -0
  87. package/dist/types/trace.js +1 -0
  88. package/guides/EXAMPLE_VERIFICATION.md +4 -4
  89. package/package.json +13 -4
@@ -1,6 +1,6 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { SuspendError } from './durable/RunStore.js';
3
- import { clockEffectKey, pauseEffectKey, toolEffectKey, } from './durable/idempotency.js';
3
+ import { clockEffectKey, idempotencyKey, logicalRunId, pauseEffectKey, } from './durable/idempotency.js';
4
4
  import { findStepByKey } from './durable/replay.js';
5
5
  import { ToolApprovalDeniedError } from '../tools/effect/errors.js';
6
6
  const APPROVAL_SIGNAL = '__approval';
@@ -12,58 +12,114 @@ function makeCtx(deps) {
12
12
  uuid: () => randomUUID(),
13
13
  };
14
14
  const emit = deps.emit ?? (() => { });
15
+ // Mutable holder: handoff reassigns runCtx.toolExecutor; ctx.tool must see the swap.
16
+ const toolExecutorHolder = { executor: deps.toolExecutor };
15
17
  const consumeCallsite = () => {
16
18
  const site = String(effectOrdinal);
17
19
  effectOrdinal += 1;
18
20
  return site;
19
21
  };
20
- const appendLiveStep = async (key, kind, name, result, signalId) => {
22
+ const isFinishedStep = (hit) => {
23
+ if (hit.status === 'finished') {
24
+ return true;
25
+ }
26
+ if (hit.status === 'running') {
27
+ return false;
28
+ }
29
+ if (hit.status === 'error' || hit.error) {
30
+ return false;
31
+ }
32
+ return hit.result !== undefined;
33
+ };
34
+ const updateLocalStep = (key, patch) => {
35
+ const idx = steps.findIndex((step) => step.key === key);
36
+ if (idx >= 0) {
37
+ steps[idx] = { ...steps[idx], ...patch };
38
+ }
39
+ };
40
+ const appendPendingStep = async (key, kind, name, index, signalId) => {
21
41
  const startedAt = Date.now();
22
42
  const record = {
23
- index: steps.length,
43
+ index,
24
44
  key,
25
45
  kind,
26
46
  name,
27
47
  signalId,
28
- result,
48
+ status: 'running',
29
49
  startedAt,
30
- finishedAt: startedAt,
50
+ epoch: deps.runState.runEpoch ?? 0,
31
51
  };
32
52
  await deps.runStore.appendStep(deps.runState.runId, record);
33
- steps.push(record);
34
- deps.runState.updatedAt = startedAt;
35
- await deps.runStore.putRunState(deps.runState);
53
+ const localIdx = steps.findIndex((step) => step.index === index);
54
+ if (localIdx >= 0) {
55
+ steps[localIdx] = record;
56
+ }
57
+ else {
58
+ while (steps.length < index) {
59
+ steps.push({
60
+ index: steps.length,
61
+ key: `__reserve:${deps.runState.runId}:${steps.length}`,
62
+ kind: 'tool',
63
+ name: '__reserve',
64
+ status: 'running',
65
+ startedAt,
66
+ epoch: deps.runState.runEpoch ?? 0,
67
+ });
68
+ }
69
+ if (steps.length === index) {
70
+ steps.push(record);
71
+ }
72
+ else {
73
+ steps[index] = record;
74
+ }
75
+ }
36
76
  };
37
- const replayOrExecute = async (key, kind, name, execute) => {
77
+ const replayOrExecute = async (key, kind, name, execute, options) => {
38
78
  const hit = findStepByKey(steps, key);
39
79
  if (hit) {
40
- if (hit.error) {
80
+ if (hit.status === 'error' || hit.error) {
41
81
  throw Object.assign(new Error(hit.error.message), { name: hit.error.name });
42
82
  }
43
- return hit.result;
83
+ if (isFinishedStep(hit)) {
84
+ return hit.result;
85
+ }
86
+ }
87
+ const isRetry = hit?.status === 'running';
88
+ const stepIndex = options?.index ?? hit?.index ?? steps.length;
89
+ if (!isRetry) {
90
+ await appendPendingStep(key, kind, name, stepIndex);
44
91
  }
92
+ let result;
45
93
  try {
46
- const result = await execute();
47
- await appendLiveStep(key, kind, name, result);
48
- return result;
94
+ result = await execute();
49
95
  }
50
96
  catch (error) {
51
97
  const err = error instanceof Error ? error : new Error(String(error));
52
- const startedAt = Date.now();
53
- const record = {
54
- index: steps.length,
55
- key,
56
- kind,
57
- name,
98
+ const finishedAt = Date.now();
99
+ await deps.runStore.finalizeStep(deps.runState.runId, key, {
100
+ status: 'error',
101
+ error: { name: err.name, message: err.message },
102
+ finishedAt,
103
+ });
104
+ updateLocalStep(key, {
105
+ status: 'error',
58
106
  error: { name: err.name, message: err.message },
59
- startedAt,
60
- finishedAt: startedAt,
61
- };
62
- await deps.runStore.appendStep(deps.runState.runId, record);
63
- steps.push(record);
107
+ finishedAt,
108
+ });
64
109
  throw err;
65
110
  }
111
+ const finishedAt = Date.now();
112
+ await deps.runStore.finalizeStep(deps.runState.runId, key, {
113
+ status: 'finished',
114
+ result,
115
+ finishedAt,
116
+ });
117
+ updateLocalStep(key, { status: 'finished', result, finishedAt });
118
+ deps.runState.updatedAt = finishedAt;
119
+ await deps.runStore.putRunState(deps.runState);
120
+ return result;
66
121
  };
122
+ const effectRunId = () => logicalRunId(deps.runState.runId, deps.runState.runEpoch);
67
123
  const suspendForSignal = async (signalName, callsite, meta) => {
68
124
  deps.runState.waitingFor = {
69
125
  signalName,
@@ -80,13 +136,15 @@ function makeCtx(deps) {
80
136
  };
81
137
  const pauseEffect = async (signalName, meta) => {
82
138
  const callsite = consumeCallsite();
83
- const key = pauseEffectKey(deps.runState.runId, callsite, signalName);
139
+ const key = pauseEffectKey(effectRunId(), callsite, signalName);
84
140
  const hit = findStepByKey(steps, key);
85
141
  if (hit) {
86
- if (hit.error) {
142
+ if (hit.status === 'error' || hit.error) {
87
143
  throw Object.assign(new Error(hit.error.message), { name: hit.error.name });
88
144
  }
89
- return hit.result;
145
+ if (hit.status === 'finished' || hit.result !== undefined) {
146
+ return hit.result;
147
+ }
90
148
  }
91
149
  await suspendForSignal(signalName, callsite, meta);
92
150
  throw new Error('unreachable');
@@ -96,7 +154,12 @@ function makeCtx(deps) {
96
154
  runState: deps.runState,
97
155
  runStore: deps.runStore,
98
156
  emit,
99
- toolExecutor: deps.toolExecutor,
157
+ get toolExecutor() {
158
+ return toolExecutorHolder.executor;
159
+ },
160
+ set toolExecutor(executor) {
161
+ toolExecutorHolder.executor = executor;
162
+ },
100
163
  hookRunner: deps.hookRunner ?? {},
101
164
  model: deps.model,
102
165
  controlModel: deps.controlModel ?? deps.model,
@@ -121,6 +184,13 @@ function makeCtx(deps) {
121
184
  resetCallsites: () => {
122
185
  effectOrdinal = 0;
123
186
  },
187
+ reserveCallsites: (count) => {
188
+ const sites = [];
189
+ for (let i = 0; i < count; i++) {
190
+ sites.push(consumeCallsite());
191
+ }
192
+ return sites;
193
+ },
124
194
  tool: async (name, args, options) => {
125
195
  // needsApproval gate: a tool flagged `needsApproval` must be approved by a human
126
196
  // before it runs. Approval is a durable pause (the `__approval` signal); on resume
@@ -129,7 +199,7 @@ function makeCtx(deps) {
129
199
  // ordering is deterministic across replays. NOTE: the surrounding agent turn is not
130
200
  // itself a replayable effect — this is fully deterministic for flow `action` tools;
131
201
  // for model-issued tool calls, resume re-enters the agent turn.
132
- const def = options?.def ?? deps.toolExecutor.getTool?.(name);
202
+ const def = options?.def ?? toolExecutorHolder.executor.getTool?.(name);
133
203
  if (def?.needsApproval) {
134
204
  const decision = (await pauseEffect(APPROVAL_SIGNAL, {
135
205
  approval: { title: `Approve tool: ${name}` },
@@ -138,9 +208,12 @@ function makeCtx(deps) {
138
208
  throw new ToolApprovalDeniedError(name, decision.by);
139
209
  }
140
210
  }
141
- const callsite = consumeCallsite();
142
- const key = toolEffectKey(deps.runState.runId, callsite, name, args);
143
- const executeTool = () => deps.toolExecutor.execute({
211
+ const callsite = options?.callsite ?? consumeCallsite();
212
+ const logicalId = effectRunId();
213
+ const key = def?.idempotencyKey != null
214
+ ? def.idempotencyKey(args)
215
+ : idempotencyKey(logicalId, callsite, { name, args });
216
+ const executeTool = () => toolExecutorHolder.executor.execute({
144
217
  name,
145
218
  args,
146
219
  session: deps.session,
@@ -151,29 +224,9 @@ function makeCtx(deps) {
151
224
  });
152
225
  if (def?.replay === false) {
153
226
  const auditKey = `${key}:${steps.length}`;
154
- try {
155
- const result = await executeTool();
156
- await appendLiveStep(auditKey, 'tool', name, result);
157
- return result;
158
- }
159
- catch (error) {
160
- const err = error instanceof Error ? error : new Error(String(error));
161
- const startedAt = Date.now();
162
- const record = {
163
- index: steps.length,
164
- key: auditKey,
165
- kind: 'tool',
166
- name,
167
- error: { name: err.name, message: err.message },
168
- startedAt,
169
- finishedAt: startedAt,
170
- };
171
- await deps.runStore.appendStep(deps.runState.runId, record);
172
- steps.push(record);
173
- throw err;
174
- }
227
+ return replayOrExecute(auditKey, 'tool', name, executeTool, { index: options?.index });
175
228
  }
176
- return replayOrExecute(key, 'tool', name, executeTool);
229
+ return replayOrExecute(key, 'tool', name, executeTool, { index: options?.index });
177
230
  },
178
231
  approve: async (req) => {
179
232
  return pauseEffect(APPROVAL_SIGNAL, { approval: req });
@@ -186,12 +239,12 @@ function makeCtx(deps) {
186
239
  },
187
240
  now: async () => {
188
241
  const callsite = consumeCallsite();
189
- const key = clockEffectKey(deps.runState.runId, callsite, 'now');
242
+ const key = clockEffectKey(effectRunId(), callsite, 'now');
190
243
  return replayOrExecute(key, 'now', 'now', async () => clock.now());
191
244
  },
192
245
  uuid: async () => {
193
246
  const callsite = consumeCallsite();
194
- const key = clockEffectKey(deps.runState.runId, callsite, 'uuid');
247
+ const key = clockEffectKey(effectRunId(), callsite, 'uuid');
195
248
  return replayOrExecute(key, 'uuid', 'uuid', async () => clock.uuid());
196
249
  },
197
250
  };
@@ -12,9 +12,25 @@ export declare class SuspendError extends Error {
12
12
  export declare class RunNotFoundError extends Error {
13
13
  constructor(runId: string);
14
14
  }
15
+ export declare class StepNotFoundError extends Error {
16
+ constructor(runId: string, key: string);
17
+ }
18
+ export interface StepFinalizePatch {
19
+ status: 'finished' | 'error';
20
+ result?: unknown;
21
+ error?: {
22
+ name: string;
23
+ message: string;
24
+ };
25
+ finishedAt?: number;
26
+ }
15
27
  export interface RunStore {
16
28
  appendStep(runId: string, record: StepRecord): Promise<void>;
29
+ finalizeStep(runId: string, key: string, patch: StepFinalizePatch): Promise<void>;
17
30
  getSteps(runId: string): Promise<StepRecord[]>;
18
31
  getRunState(runId: string): Promise<RunState | null>;
19
32
  putRunState(state: RunState): Promise<void>;
33
+ initRun?(state: RunState): Promise<void>;
34
+ pruneStepsBeforeEpoch?(runId: string, keepEpoch: number): Promise<void>;
35
+ reserveSteps?(runId: string, count: number): Promise<number[]>;
20
36
  }
@@ -24,3 +24,9 @@ export class RunNotFoundError extends Error {
24
24
  this.name = 'RunNotFoundError';
25
25
  }
26
26
  }
27
+ export class StepNotFoundError extends Error {
28
+ constructor(runId, key) {
29
+ super(`Step not found for run ${runId}: ${key}`);
30
+ this.name = 'StepNotFoundError';
31
+ }
32
+ }
@@ -1,14 +1,19 @@
1
- import type { SessionStore } from '../../session/SessionStore.js';
1
+ import { type SessionStore } from '../../session/SessionStore.js';
2
2
  import type { RunState, StepRecord } from './types.js';
3
- import { type RunStore } from './RunStore.js';
3
+ import { type RunStore, type StepFinalizePatch } from './RunStore.js';
4
4
  export declare class SessionRunStore implements RunStore {
5
5
  private readonly sessionStore;
6
6
  private readonly sessionId;
7
+ private static readonly CAS_RETRIES;
7
8
  constructor(sessionStore: SessionStore, sessionId: string);
9
+ private mutateSession;
8
10
  appendStep(runId: string, record: StepRecord): Promise<void>;
11
+ finalizeStep(runId: string, key: string, patch: StepFinalizePatch): Promise<void>;
12
+ reserveSteps(runId: string, count: number): Promise<number[]>;
9
13
  getSteps(runId: string): Promise<StepRecord[]>;
10
14
  getRunState(runId: string): Promise<RunState | null>;
11
15
  putRunState(state: RunState): Promise<void>;
12
16
  initRun(state: RunState): Promise<void>;
17
+ pruneStepsBeforeEpoch(runId: string, keepEpoch: number): Promise<void>;
13
18
  private requireSession;
14
19
  }
@@ -1,5 +1,6 @@
1
+ import { StaleWriteError } from '../../session/SessionStore.js';
1
2
  import { DURABLE_RUNS_KEY } from './types.js';
2
- import { LogConflictError, RunNotFoundError } from './RunStore.js';
3
+ import { LogConflictError, RunNotFoundError, StepNotFoundError, } from './RunStore.js';
3
4
  function cloneSession(value) {
4
5
  try {
5
6
  return structuredClone(value);
@@ -21,28 +22,115 @@ function getPersistedRun(session, runId) {
21
22
  export class SessionRunStore {
22
23
  sessionStore;
23
24
  sessionId;
25
+ static CAS_RETRIES = 8;
24
26
  constructor(sessionStore, sessionId) {
25
27
  this.sessionStore = sessionStore;
26
28
  this.sessionId = sessionId;
27
29
  }
28
- async appendStep(runId, record) {
29
- const session = await this.requireSession();
30
- const runs = readRuns(session);
31
- const persisted = runs[runId];
32
- if (!persisted) {
33
- throw new RunNotFoundError(runId);
30
+ async mutateSession(mutator) {
31
+ for (let attempt = 0; attempt < SessionRunStore.CAS_RETRIES; attempt++) {
32
+ const session = await this.requireSession();
33
+ await mutator(session);
34
+ try {
35
+ await this.sessionStore.save(session);
36
+ return;
37
+ }
38
+ catch (error) {
39
+ if (error instanceof StaleWriteError && attempt < SessionRunStore.CAS_RETRIES - 1) {
40
+ continue;
41
+ }
42
+ throw error;
43
+ }
34
44
  }
35
- if (persisted.steps.length !== record.index) {
36
- throw new LogConflictError(runId, record.index, persisted.steps.length);
37
- }
38
- if (persisted.steps.some((step) => step.key === record.key)) {
39
- throw new LogConflictError(runId, record.index, persisted.steps.length);
45
+ }
46
+ async appendStep(runId, record) {
47
+ await this.mutateSession((session) => {
48
+ const runs = readRuns(session);
49
+ const persisted = runs[runId];
50
+ if (!persisted) {
51
+ throw new RunNotFoundError(runId);
52
+ }
53
+ const existingAtIndex = persisted.steps[record.index];
54
+ if (existingAtIndex?.name === '__reserve') {
55
+ if (persisted.steps.some((step) => step.key === record.key && step.index !== record.index)) {
56
+ throw new LogConflictError(runId, record.index, persisted.steps.length);
57
+ }
58
+ persisted.steps[record.index] = cloneSession(record);
59
+ }
60
+ else if (persisted.steps.length !== record.index) {
61
+ throw new LogConflictError(runId, record.index, persisted.steps.length);
62
+ }
63
+ else if (persisted.steps.some((step) => step.key === record.key)) {
64
+ throw new LogConflictError(runId, record.index, persisted.steps.length);
65
+ }
66
+ else {
67
+ persisted.steps.push(cloneSession(record));
68
+ }
69
+ persisted.runState.updatedAt = Date.now();
70
+ runs[runId] = persisted;
71
+ writeRuns(session, runs);
72
+ });
73
+ }
74
+ async finalizeStep(runId, key, patch) {
75
+ await this.mutateSession((session) => {
76
+ const runs = readRuns(session);
77
+ const persisted = runs[runId];
78
+ if (!persisted) {
79
+ throw new RunNotFoundError(runId);
80
+ }
81
+ const stepIndex = persisted.steps.findIndex((step) => step.key === key);
82
+ if (stepIndex === -1) {
83
+ throw new StepNotFoundError(runId, key);
84
+ }
85
+ const existing = persisted.steps[stepIndex];
86
+ persisted.steps[stepIndex] = cloneSession({
87
+ ...existing,
88
+ ...patch,
89
+ index: existing.index,
90
+ key: existing.key,
91
+ kind: existing.kind,
92
+ name: existing.name,
93
+ startedAt: existing.startedAt,
94
+ epoch: existing.epoch,
95
+ });
96
+ persisted.runState.updatedAt = Date.now();
97
+ runs[runId] = persisted;
98
+ writeRuns(session, runs);
99
+ });
100
+ }
101
+ async reserveSteps(runId, count) {
102
+ if (count <= 0) {
103
+ return [];
40
104
  }
41
- persisted.steps.push(cloneSession(record));
42
- persisted.runState.updatedAt = Date.now();
43
- runs[runId] = persisted;
44
- writeRuns(session, runs);
45
- await this.sessionStore.save(session);
105
+ let indices = [];
106
+ await this.mutateSession((session) => {
107
+ const runs = readRuns(session);
108
+ const persisted = runs[runId];
109
+ if (!persisted) {
110
+ throw new RunNotFoundError(runId);
111
+ }
112
+ const start = persisted.steps.length;
113
+ const now = Date.now();
114
+ const epoch = persisted.runState.runEpoch ?? 0;
115
+ indices = [];
116
+ for (let i = 0; i < count; i++) {
117
+ const index = start + i;
118
+ indices.push(index);
119
+ persisted.steps.push({
120
+ index,
121
+ key: `__reserve:${runId}:${index}`,
122
+ kind: 'tool',
123
+ name: '__reserve',
124
+ status: 'running',
125
+ startedAt: now,
126
+ epoch,
127
+ });
128
+ }
129
+ persisted.runState.updatedAt = now;
130
+ runs[runId] = persisted;
131
+ writeRuns(session, runs);
132
+ });
133
+ return indices;
46
134
  }
47
135
  async getSteps(runId) {
48
136
  const session = await this.requireSession();
@@ -58,25 +146,39 @@ export class SessionRunStore {
58
146
  return persisted ? cloneSession(persisted.runState) : null;
59
147
  }
60
148
  async putRunState(state) {
61
- const session = await this.requireSession();
62
- const runs = readRuns(session);
63
- const existing = runs[state.runId];
64
- runs[state.runId] = {
65
- runState: cloneSession({ ...state, updatedAt: Date.now() }),
66
- steps: existing?.steps.map((step) => cloneSession(step)) ?? [],
67
- };
68
- writeRuns(session, runs);
69
- await this.sessionStore.save(session);
149
+ await this.mutateSession((session) => {
150
+ const runs = readRuns(session);
151
+ const existing = runs[state.runId];
152
+ runs[state.runId] = {
153
+ runState: cloneSession({ ...state, updatedAt: Date.now() }),
154
+ steps: existing?.steps.map((step) => cloneSession(step)) ?? [],
155
+ };
156
+ writeRuns(session, runs);
157
+ });
70
158
  }
71
159
  async initRun(state) {
72
- const session = await this.requireSession();
73
- const runs = readRuns(session);
74
- runs[state.runId] = {
75
- runState: cloneSession(state),
76
- steps: [],
77
- };
78
- writeRuns(session, runs);
79
- await this.sessionStore.save(session);
160
+ await this.mutateSession((session) => {
161
+ const runs = readRuns(session);
162
+ runs[state.runId] = {
163
+ runState: cloneSession(state),
164
+ steps: [],
165
+ };
166
+ writeRuns(session, runs);
167
+ });
168
+ }
169
+ async pruneStepsBeforeEpoch(runId, keepEpoch) {
170
+ await this.mutateSession((session) => {
171
+ const runs = readRuns(session);
172
+ const persisted = runs[runId];
173
+ if (!persisted) {
174
+ return;
175
+ }
176
+ const kept = persisted.steps.filter((step) => step.epoch === undefined || step.epoch >= keepEpoch);
177
+ persisted.steps = kept.map((step, index) => ({ ...cloneSession(step), index }));
178
+ persisted.runState.updatedAt = Date.now();
179
+ runs[runId] = persisted;
180
+ writeRuns(session, runs);
181
+ });
80
182
  }
81
183
  async requireSession() {
82
184
  const session = await this.sessionStore.get(this.sessionId);
@@ -1,3 +1,4 @@
1
+ export declare function logicalRunId(runId: string, runEpoch: number | undefined): string;
1
2
  export declare function idempotencyKey(runId: string, callsite: string, payload: unknown): string;
2
3
  export declare function toolEffectKey(runId: string, callsite: string, name: string, args: unknown): string;
3
4
  export declare function pauseEffectKey(runId: string, callsite: string, name: string): string;
@@ -12,6 +12,9 @@ function stableStringify(value) {
12
12
  return val;
13
13
  });
14
14
  }
15
+ export function logicalRunId(runId, runEpoch) {
16
+ return `${runId}#${runEpoch ?? 0}`;
17
+ }
15
18
  export function idempotencyKey(runId, callsite, payload) {
16
19
  const material = stableStringify({ runId, callsite, payload });
17
20
  return createHash('sha256').update(material).digest('hex');
@@ -1,4 +1,4 @@
1
- import { pauseEffectKey } from './idempotency.js';
1
+ import { pauseEffectKey, logicalRunId } from './idempotency.js';
2
2
  export function findStepByKey(steps, key) {
3
3
  return steps.find((step) => step.key === key);
4
4
  }
@@ -14,7 +14,10 @@ export async function recordSignalDelivery(runStore, runState, delivery) {
14
14
  if (!waitingFor || waitingFor.signalName !== delivery.name) {
15
15
  throw new Error(`Signal ${delivery.name} does not match waitingFor ${waitingFor?.signalName ?? 'none'}`);
16
16
  }
17
- const key = pauseEffectKey(runState.runId, waitingFor.callsite, delivery.name);
17
+ // Must match the key pauseEffect used when it suspended: the keystone scopes the
18
+ // effect-key namespace by logical run (runId#epoch), so the resume-side delivery
19
+ // key has to use logicalRunId too, else the pause never finds its own decision.
20
+ const key = pauseEffectKey(logicalRunId(runState.runId, runState.runEpoch), waitingFor.callsite, delivery.name);
18
21
  if (findStepByKey(steps, key)) {
19
22
  return false;
20
23
  }
@@ -25,9 +28,11 @@ export async function recordSignalDelivery(runStore, runState, delivery) {
25
28
  kind: waitingFor.approval ? 'approval' : 'signal',
26
29
  name: delivery.name,
27
30
  signalId: delivery.signalId,
31
+ status: 'finished',
28
32
  result: delivery.payload,
29
33
  startedAt: now,
30
34
  finishedAt: now,
35
+ epoch: runState.runEpoch ?? 0,
31
36
  };
32
37
  await runStore.appendStep(runState.runId, record);
33
38
  runState.waitingFor = undefined;
@@ -1,5 +1,6 @@
1
1
  import type { ModelMessage } from 'ai';
2
2
  export type StepKind = 'tool' | 'approval' | 'signal' | 'now' | 'uuid';
3
+ export type StepStatus = 'running' | 'paused' | 'finished' | 'error' | 'aborted';
3
4
  export interface StepRecord {
4
5
  index: number;
5
6
  key: string;
@@ -11,8 +12,12 @@ export interface StepRecord {
11
12
  name: string;
12
13
  message: string;
13
14
  };
15
+ /** Intent lifecycle: `running` = pending execute; `finished`/`error` = finalized. Legacy steps omit status. */
16
+ status?: StepStatus;
14
17
  startedAt: number;
15
18
  finishedAt?: number;
19
+ /** Logical-run epoch when this step was recorded. Absent on legacy steps → prune keeps them until superseded. */
20
+ epoch?: number;
16
21
  }
17
22
  interface WaitingFor {
18
23
  signalName: string;
@@ -36,6 +41,12 @@ export interface RunState {
36
41
  messages: ModelMessage[];
37
42
  createdAt: number;
38
43
  updatedAt: number;
44
+ /** Monotonic logical-run counter. Increments on each fresh turn; stable across suspend/resume.
45
+ * Scopes the durable effect-key namespace so a new turn re-executes rather than replaying a
46
+ * prior turn's cached result (F6/G8). Absent on legacy runs → treat as 0. */
47
+ runEpoch?: number;
48
+ /** Inbound message idempotency keys already accepted (H2 webhook-retry dedup). */
49
+ processedInboundKeys?: string[];
39
50
  }
40
51
  export interface SignalDelivery {
41
52
  signalId: string;
@@ -0,0 +1,18 @@
1
+ import type { LanguageModel } from 'ai';
2
+ import type { RunContext } from '../types/run-context.js';
3
+ export declare const GOALS_KEY = "__goals";
4
+ export type GoalStatus = 'open' | 'resolved';
5
+ export interface TrackedGoal {
6
+ topic: string;
7
+ status: GoalStatus;
8
+ lastTurn: number;
9
+ note?: string;
10
+ }
11
+ export type GoalsState = Record<string, unknown>;
12
+ export declare function getGoals(state: GoalsState): TrackedGoal[];
13
+ export declare function addGoal(state: GoalsState, topic: string, lastTurn?: number, note?: string): GoalsState;
14
+ export declare function resolveGoal(state: GoalsState, topic: string, lastTurn?: number): GoalsState;
15
+ export declare function listOpenGoals(state: GoalsState): string[];
16
+ export declare function projectGoalsPrompt(goals: TrackedGoal[]): string;
17
+ export declare function projectGoalsPromptFromState(state: GoalsState): string;
18
+ export declare function updateGoalsFromTurn(ctx: RunContext, model: LanguageModel): Promise<void>;