@lensagent/core 0.1.0 → 0.1.2

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 +63 -4
  2. package/config/lens.example.jsonc +36 -13
  3. package/dist/adapters/executor-router.d.ts +20 -0
  4. package/dist/adapters/executor-router.js +86 -0
  5. package/dist/adapters/executor.d.ts +9 -3
  6. package/dist/adapters/executor.js +196 -61
  7. package/dist/adapters/lsp.d.ts +2 -1
  8. package/dist/adapters/lsp.js +98 -41
  9. package/dist/adapters/mcp.js +52 -17
  10. package/dist/adapters/revert.d.ts +5 -4
  11. package/dist/adapters/revert.js +6 -64
  12. package/dist/adapters/web.d.ts +9 -0
  13. package/dist/adapters/web.js +244 -0
  14. package/dist/config/files.d.ts +14 -0
  15. package/dist/config/files.js +57 -0
  16. package/dist/config/runtime.d.ts +3 -4
  17. package/dist/config/runtime.js +116 -97
  18. package/dist/config/schema.d.ts +56 -10
  19. package/dist/config/schema.js +48 -8
  20. package/dist/contracts/capability.d.ts +27 -1
  21. package/dist/contracts/domain.d.ts +1 -0
  22. package/dist/contracts/model.d.ts +33 -6
  23. package/dist/contracts/review.d.ts +17 -0
  24. package/dist/contracts/review.js +1 -0
  25. package/dist/core/access-policy.d.ts +40 -0
  26. package/dist/core/access-policy.js +68 -0
  27. package/dist/core/access.d.ts +30 -2
  28. package/dist/core/access.js +159 -26
  29. package/dist/core/context-boundaries.d.ts +25 -0
  30. package/dist/core/context-boundaries.js +106 -0
  31. package/dist/core/context.d.ts +3 -0
  32. package/dist/core/context.js +11 -0
  33. package/dist/core/host.js +261 -173
  34. package/dist/entry/pair.d.ts +1 -0
  35. package/dist/entry/pair.js +14 -0
  36. package/dist/entry/run.js +1 -1
  37. package/dist/execution/lsp-process.d.ts +3 -2
  38. package/dist/execution/lsp-process.js +46 -28
  39. package/dist/http/server.d.ts +5 -0
  40. package/dist/http/server.js +262 -80
  41. package/dist/runtime/agent.d.ts +9 -4
  42. package/dist/runtime/agent.js +361 -286
  43. package/dist/runtime/artifacts.d.ts +13 -1
  44. package/dist/runtime/artifacts.js +137 -10
  45. package/dist/runtime/cache.d.ts +59 -16
  46. package/dist/runtime/cache.js +124 -43
  47. package/dist/runtime/calls.d.ts +1 -1
  48. package/dist/runtime/calls.js +164 -48
  49. package/dist/runtime/capabilities.d.ts +2 -2
  50. package/dist/runtime/capabilities.js +4 -5
  51. package/dist/runtime/cleanup.d.ts +5 -2
  52. package/dist/runtime/cleanup.js +48 -17
  53. package/dist/runtime/compaction.d.ts +9 -0
  54. package/dist/runtime/compaction.js +52 -0
  55. package/dist/runtime/context.d.ts +8 -4
  56. package/dist/runtime/context.js +115 -128
  57. package/dist/runtime/events.d.ts +7 -0
  58. package/dist/runtime/events.js +83 -0
  59. package/dist/runtime/images.d.ts +14 -0
  60. package/dist/runtime/images.js +82 -0
  61. package/dist/runtime/kernel-capabilities.d.ts +7 -1
  62. package/dist/runtime/kernel-capabilities.js +146 -68
  63. package/dist/runtime/model-audit.d.ts +9 -0
  64. package/dist/runtime/model-audit.js +39 -0
  65. package/dist/runtime/model.d.ts +109 -4
  66. package/dist/runtime/model.js +368 -143
  67. package/dist/runtime/observability.d.ts +1 -0
  68. package/dist/runtime/observability.js +60 -15
  69. package/dist/runtime/policy.d.ts +1 -1
  70. package/dist/runtime/policy.js +2 -2
  71. package/dist/runtime/profile.d.ts +2 -1
  72. package/dist/runtime/profile.js +12 -15
  73. package/dist/runtime/projects.d.ts +3 -1
  74. package/dist/runtime/projects.js +38 -17
  75. package/dist/runtime/ptc-worker.d.ts +1 -0
  76. package/dist/runtime/ptc-worker.js +97 -0
  77. package/dist/runtime/ptc.d.ts +16 -0
  78. package/dist/runtime/ptc.js +128 -0
  79. package/dist/runtime/results.d.ts +5 -4
  80. package/dist/runtime/results.js +29 -51
  81. package/dist/runtime/review.d.ts +4 -1
  82. package/dist/runtime/review.js +101 -22
  83. package/dist/runtime/scheduler.js +1 -0
  84. package/dist/runtime/skills.js +34 -5
  85. package/dist/runtime/task-audit.d.ts +21 -0
  86. package/dist/runtime/task-audit.js +122 -0
  87. package/dist/runtime/time.d.ts +9 -0
  88. package/dist/runtime/time.js +18 -0
  89. package/dist/runtime/tokenizer.d.ts +2 -0
  90. package/dist/runtime/tokenizer.js +26 -1
  91. package/dist/store/database.js +26 -1
  92. package/dist/store/event-log.d.ts +1 -0
  93. package/dist/store/event-log.js +22 -5
  94. package/dist/store/schema.d.ts +1 -1
  95. package/dist/store/schema.js +50 -1
  96. package/dist/store/store.d.ts +65 -19
  97. package/dist/store/store.js +986 -188
  98. package/package.json +18 -8
  99. package/proto/executor.proto +69 -55
@@ -1,18 +1,57 @@
1
+ import { assertContextBoundaries, assertContextTransition, boundaryMessages, contextToolCalls } from '../core/context-boundaries.js';
1
2
  import { digest, id, json, now, parseJson } from '../core/codec.js';
2
- import { fail } from '../core/domain.js';
3
+ import { fail, LensError } from '../core/domain.js';
4
+ import { estimateTokens } from '../runtime/tokenizer.js';
3
5
  import { policyPrompt } from '../runtime/policy.js';
6
+ import { activeContextItems } from '../core/context.js';
4
7
  export class Store {
8
+ observability;
5
9
  #database;
6
10
  #events;
7
11
  #eventLog;
8
- constructor(database, events, eventLog) {
12
+ #batchChanges;
13
+ /** Persist a model tool batch before reviews or execution can yield control. */
14
+ commitToolBatch(projectId, command) {
15
+ const changes = [];
16
+ return this.#write(projectId, changes, () => {
17
+ this.#batchChanges = changes;
18
+ try {
19
+ return command();
20
+ }
21
+ finally {
22
+ this.#batchChanges = undefined;
23
+ }
24
+ });
25
+ }
26
+ constructor(database, events, eventLog, observability) {
27
+ this.observability = observability;
9
28
  this.#database = database;
10
29
  this.#events = events;
11
30
  this.#eventLog = eventLog;
31
+ this.#database.transaction(() => {
32
+ const migrated = new Set(this.#all("SELECT DISTINCT agent_id FROM context_items WHERE kind = 'compaction' AND role != 'assistant'").map((row) => String(row.agent_id)));
33
+ if (migrated.size > 0) {
34
+ const timestamp = now();
35
+ this.#database.prepare(`UPDATE messages SET role = 'assistant' WHERE id IN (SELECT source_id FROM context_items WHERE kind = 'compaction')`).run();
36
+ this.#database.prepare(`UPDATE context_items SET role = 'assistant', revision = revision + 1, updated_at = ? WHERE kind = 'compaction' AND role != 'assistant'`).run(timestamp);
37
+ this.#database.prepare('UPDATE instance SET state_revision = state_revision + 1 WHERE id = 1').run();
38
+ }
39
+ for (const row of this.#all('SELECT id FROM agents')) {
40
+ const agentId = String(row.id);
41
+ if (migrated.has(agentId))
42
+ this.#touchContext(agentId);
43
+ else
44
+ this.#syncWorkingContext(agentId);
45
+ }
46
+ })();
12
47
  }
13
48
  async close() {
14
- await this.#eventLog?.close();
15
- this.#database.close();
49
+ try {
50
+ await this.#eventLog?.close();
51
+ }
52
+ finally {
53
+ this.#database.close();
54
+ }
16
55
  }
17
56
  instance() {
18
57
  return doc(this.#one('SELECT * FROM instance WHERE id = 1'));
@@ -33,23 +72,16 @@ export class Store {
33
72
  JOIN runs r ON r.id = s.run_id
34
73
  WHERE ma.kind = 'compaction' AND ma.state IN ('PREPARED', 'RUNNING')
35
74
  `).map((row) => String(row.id)));
36
- const interruptedAgentIds = new Set(this.#all(`SELECT DISTINCT agent_id FROM runs WHERE state IN ('RUNNING', 'WAITING')`).map((row) => String(row.agent_id)));
75
+ const interruptedAgentIds = new Set(this.#all(`SELECT DISTINCT agent_id FROM runs WHERE state != 'TERMINAL'`).map((row) => String(row.agent_id)));
37
76
  const attempts = this.#all("SELECT id FROM model_attempts WHERE state IN ('PREPARED', 'RUNNING')");
38
77
  for (const row of attempts)
39
78
  interrupted.push({ type: 'model_attempt', id: String(row.id) });
40
79
  this.#database.prepare(`UPDATE model_attempts SET state = 'TERMINAL', outcome = 'interrupted', error_code = 'PROCESS_INTERRUPTED', updated_at = ? WHERE state IN ('PREPARED', 'RUNNING')`).run(timestamp);
41
- const calls = this.#all(`SELECT id, state FROM calls WHERE state = 'RUNNING' OR (step_id IS NOT NULL AND state IN ('WAITING', 'QUEUED'))`);
42
- for (const row of calls) {
43
- const callId = String(row.id);
44
- interrupted.push({ type: 'call', id: callId });
45
- const running = row.state === 'RUNNING';
46
- const outcome = running ? 'interrupted' : 'cancelled';
47
- const errorCode = running ? 'PROCESS_INTERRUPTED' : 'RECOVERY_CANCELLED';
48
- this.#database.prepare(`UPDATE calls SET state = 'TERMINAL', outcome = ?, outcome_unknown = ?, revision = revision + 1, updated_at = ? WHERE id = ?`)
49
- .run(outcome, running ? 1 : 0, timestamp, callId);
50
- this.#database.prepare(`INSERT OR IGNORE INTO results(id, call_id, outcome, content_json, error_code, error_message, created_at) VALUES (?, ?, ?, '{}', ?, 'The Agent workflow was interrupted by process shutdown.', ?)`)
51
- .run(id('result'), callId, outcome, errorCode, timestamp);
80
+ interrupted.push(...this.#materializeInterruptedCalls(timestamp));
81
+ for (const row of this.#all("SELECT * FROM calls WHERE state != 'TERMINAL'")) {
82
+ interrupted.push(...this.#interruptCall(row, row.state === 'RUNNING' ? 'PROCESS_INTERRUPTED' : 'RECOVERY_CANCELLED', 'Execution stopped when the process exited; the Call will not be resumed.', timestamp));
52
83
  }
84
+ interrupted.push(...this.#recoverResultContext(timestamp));
53
85
  const ledgerSteps = this.#all(`SELECT DISTINCT s.id, r.agent_id FROM steps s JOIN runs r ON r.id = s.run_id JOIN calls c ON c.step_id = s.id WHERE s.state IN ('RUNNING', 'WAITING')`);
54
86
  for (const row of ledgerSteps) {
55
87
  const stepId = String(row.id);
@@ -81,7 +113,7 @@ export class Store {
81
113
  .run(compactionFailed ? 'failed' : 'interrupted', compactionFailed ? 'COMPACTION_FAILED' : 'PROCESS_INTERRUPTED', timestamp, stepId);
82
114
  }
83
115
  this.#database.prepare(`UPDATE context_turn_groups SET state = 'ABORTED', revision = revision + 1, completed_at = ? WHERE state = 'OPEN'`).run(timestamp);
84
- const runs = this.#all("SELECT id FROM runs WHERE state IN ('RUNNING', 'WAITING')");
116
+ const runs = this.#all("SELECT id FROM runs WHERE state != 'TERMINAL'");
85
117
  for (const row of runs) {
86
118
  const runId = String(row.id);
87
119
  const compactionFailed = compactionRunIds.has(runId);
@@ -97,6 +129,7 @@ export class Store {
97
129
  this.#database.prepare('UPDATE instance SET state_revision = state_revision + 1 WHERE id = 1').run();
98
130
  });
99
131
  transaction();
132
+ this.#observe(interrupted);
100
133
  return interrupted;
101
134
  }
102
135
  /**
@@ -115,7 +148,7 @@ export class Store {
115
148
  getProject(projectId) {
116
149
  return doc(this.#require('projects', projectId));
117
150
  }
118
- createProject(name, paths) {
151
+ createProject(name, paths, executorId = 'remote') {
119
152
  const projectId = id('project');
120
153
  const workspaceId = id('workspace');
121
154
  const timestamp = now();
@@ -125,6 +158,7 @@ export class Store {
125
158
  this.#database.prepare(`INSERT INTO workspaces(id, project_id, name, is_default, revision, created_at, updated_at) VALUES (?, ?, 'Default', 1, 0, ?, ?)`)
126
159
  .run(workspaceId, projectId, timestamp, timestamp);
127
160
  this.#replaceWorkspacePaths(workspaceId, paths);
161
+ this.#database.prepare('UPDATE workspaces SET executor_ref = ? WHERE id = ?').run(executorId, workspaceId);
128
162
  return this.getProject(projectId);
129
163
  });
130
164
  }
@@ -183,7 +217,7 @@ export class Store {
183
217
  owned(row, 'project_id', projectId);
184
218
  return this.#workspace(row);
185
219
  }
186
- createWorkspace(projectId, name, paths, makeDefault) {
220
+ createWorkspace(projectId, name, paths, makeDefault, executorId = 'remote') {
187
221
  const workspaceId = id('workspace');
188
222
  const timestamp = now();
189
223
  return this.#write(projectId, refs(['workspace', workspaceId]), () => {
@@ -198,6 +232,7 @@ export class Store {
198
232
  .run(workspaceId, projectId, name, makeDefault ? 1 : 0, timestamp, timestamp);
199
233
  });
200
234
  this.#replaceWorkspacePaths(workspaceId, paths);
235
+ this.#database.prepare('UPDATE workspaces SET executor_ref = ? WHERE id = ?').run(executorId, workspaceId);
201
236
  return this.getWorkspace(projectId, workspaceId);
202
237
  });
203
238
  }
@@ -324,14 +359,14 @@ export class Store {
324
359
  return this.getTask(projectId, taskId);
325
360
  });
326
361
  }
327
- deleteTask(projectId, taskId, expectedRevision) {
362
+ deleteTask(projectId, taskId, expectedRevision, executorStateId) {
328
363
  return this.#write(projectId, refs(['task', taskId]), () => {
329
364
  const current = this.#require('tasks', taskId);
330
365
  owned(current, 'project_id', projectId);
331
366
  this.#expect(current, expectedRevision);
332
367
  if (current.archived_at === null)
333
368
  fail('TASK_NOT_ARCHIVED', 409, 'Archive the Task before deleting it.');
334
- return this.#purgeTaskRows([taskId]);
369
+ return this.#purgeTaskRows(projectId, [taskId], executorStateId);
335
370
  });
336
371
  }
337
372
  listSessions(projectId, taskId, options, archived = false) {
@@ -352,10 +387,10 @@ export class Store {
352
387
  #sessionSummary(session, projectId) {
353
388
  const sessionId = stringField(session.id);
354
389
  const rootAgent = this.#all('SELECT id FROM agents WHERE session_id = ? AND parent_agent_id IS NULL ORDER BY created_at, id LIMIT 1', sessionId)[0];
355
- const running = this.#count(`SELECT COUNT(*) AS count FROM runs r JOIN agents a ON a.id = r.agent_id
356
- WHERE a.session_id = ? AND r.state = 'RUNNING'`, sessionId);
357
- const waiting = this.#count(`SELECT COUNT(*) AS count FROM runs r JOIN agents a ON a.id = r.agent_id
358
- WHERE a.session_id = ? AND r.state = 'WAITING'`, sessionId);
390
+ const phases = this.#all("SELECT r.* FROM runs r JOIN agents a ON a.id = r.agent_id WHERE a.session_id = ? AND r.state != 'TERMINAL'", sessionId)
391
+ .map(run => this.#runPhase(doc(run)));
392
+ const waiting = phases.filter(phase => phase === 'WAITING_USER').length;
393
+ const running = phases.filter(phase => phase !== 'WAITING_USER' && phase !== 'QUEUED').length;
359
394
  const pending = this.#count(`SELECT COUNT(*) AS count FROM runs r JOIN agents a ON a.id = r.agent_id
360
395
  WHERE a.session_id = ? AND r.state <> 'TERMINAL'`, sessionId);
361
396
  // The outcome of the most recent Run, not a count of every Run that ever failed. Counting made the
@@ -414,11 +449,11 @@ export class Store {
414
449
  };
415
450
  }
416
451
  /**
417
- * 02A `AgentSessionRuntimeSummary`. Only `context_revision` is stored today; the Work Context and
418
- * token-estimate fields have no column yet, so they report `null` rather than a fabricated value.
452
+ * 02A `AgentSessionRuntimeSummary`. Source and Working Context revisions are independent.
419
453
  */
420
454
  #agentRuntime(agent) {
421
455
  const agentId = stringField(agent.id);
456
+ const working = this.#database.prepare('SELECT revision, digest FROM working_contexts WHERE agent_id = ?').get(agentId);
422
457
  const pending = this.#all('SELECT COUNT(*) AS count, MAX(ordinal) AS high FROM pending_messages WHERE agent_id = ?', agentId)[0];
423
458
  return {
424
459
  session_id: agent.session_id ?? null,
@@ -428,8 +463,8 @@ export class Store {
428
463
  context_high_water: this.#count('SELECT COUNT(*) AS count FROM context_items WHERE agent_id = ?', agentId),
429
464
  pending_seq_high_water: Number(pending?.high ?? 0),
430
465
  pending_revision: Number(pending?.count ?? 0),
431
- work_context_revision: null,
432
- work_context_digest: null,
466
+ work_context_revision: working === undefined ? null : Number(working.revision),
467
+ work_context_digest: working === undefined ? null : String(working.digest),
433
468
  request_context_frame_digest: null,
434
469
  token_estimate: null
435
470
  };
@@ -455,9 +490,10 @@ export class Store {
455
490
  session_id: run.agent_id ?? null,
456
491
  agent_session_id: run.agent_id ?? null,
457
492
  retry_of_run_id: run.retry_of_run_id ?? null,
458
- initial_step_pending: run.initial_step_pending === 1,
493
+ initial_step_pending: run.initial_step_pending === true,
459
494
  tool_continuation_pending: false,
460
- status: state === 'TERMINAL' && outcome !== null ? terminal[outcome] ?? 'FAILED' : state,
495
+ phase: this.#runPhase(run),
496
+ status: state === 'TERMINAL' && outcome !== null ? terminal[outcome] ?? 'FAILED' : this.#runPhase(run) === 'WAITING_USER' ? 'WAITING' : state,
461
497
  started_at: run.started_at ?? null,
462
498
  ended_at: run.ended_at ?? null,
463
499
  error_code: run.error_code ?? null
@@ -613,6 +649,30 @@ export class Store {
613
649
  * `ended_at` have no storage at all, so they report `false`/`null` rather than a fabricated value,
614
650
  * and `revision` is null because `steps` carries no revision column to report.
615
651
  */
652
+ #runPhase(run) {
653
+ if (run.state === 'TERMINAL')
654
+ return 'TERMINAL';
655
+ if (run.state === 'READY')
656
+ return 'QUEUED';
657
+ const step = this.#database.prepare("SELECT * FROM steps WHERE run_id = ? AND state != 'TERMINAL' ORDER BY ordinal DESC LIMIT 1").get(run.id);
658
+ return step === undefined ? 'PREPARING' : this.#stepPhase(doc(step));
659
+ }
660
+ #stepPhase(step) {
661
+ if (step.state === 'TERMINAL')
662
+ return 'TERMINAL';
663
+ if (this.#count("SELECT COUNT(*) AS count FROM model_attempts WHERE step_id = ? AND state IN ('PREPARED', 'RUNNING')", step.id) > 0)
664
+ return 'MODEL_STREAMING';
665
+ const calls = this.#all("SELECT state, wait_reason FROM calls WHERE step_id = ? AND state != 'TERMINAL'", step.id);
666
+ if (calls.some(call => call.state === 'RUNNING'))
667
+ return 'TOOLS_RUNNING';
668
+ if (calls.some(call => call.state === 'WAITING' && call.wait_reason === 'policy_review'))
669
+ return 'POLICY_REVIEW';
670
+ if (calls.some(call => call.state === 'WAITING'))
671
+ return 'WAITING_USER';
672
+ if (calls.some(call => call.state === 'QUEUED'))
673
+ return 'TOOLS_QUEUED';
674
+ return 'PREPARING';
675
+ }
616
676
  #stepRuntime(step) {
617
677
  const state = step.state ?? null;
618
678
  const outcome = typeof step.outcome === 'string' ? step.outcome : null;
@@ -628,8 +688,9 @@ export class Store {
628
688
  outcome,
629
689
  status: state === 'TERMINAL'
630
690
  ? terminal[outcome ?? ''] ?? 'FAILED'
631
- : state === 'WAITING' ? 'WAITING_CALLS' : 'MODEL_STREAMING',
632
- stream_closed: state !== 'RUNNING',
691
+ : this.#stepPhase(step),
692
+ phase: this.#stepPhase(step),
693
+ stream_closed: this.#stepPhase(step) !== 'MODEL_STREAMING',
633
694
  reactive_compaction_attempted: false,
634
695
  assistant_message_id: null,
635
696
  ended_at: null,
@@ -702,6 +763,8 @@ export class Store {
702
763
  ...(queuedIds === null ? [] : messageRefs(queuedIds))
703
764
  ];
704
765
  return this.#write(projectId, refs(...changes), () => {
766
+ if (input.expectedParentContextRevision !== undefined && this.getAgent(projectId, parentAgentId).context_revision !== input.expectedParentContextRevision)
767
+ fail('REVISION_CONFLICT', 409, 'Parent Context changed while preparing the Subagent.');
705
768
  const timestamp = now();
706
769
  this.#insertAgent(projectId, agentId, {
707
770
  taskId: stringField(parent.task_id),
@@ -716,12 +779,20 @@ export class Store {
716
779
  loadedCapabilities: input.loadedCapabilities
717
780
  }, timestamp);
718
781
  if (input.forkContext === 'full') {
719
- const boundary = this.#database.prepare(`SELECT MAX(ordinal) AS ordinal FROM context_items WHERE agent_id = ? AND turn_group_id IN (SELECT id FROM context_turn_groups WHERE agent_id = ? AND state = 'COMPLETE')`).get(parentAgentId, parentAgentId);
720
- if (boundary.ordinal !== null)
721
- this.#copyContextRange(parentAgentId, agentId, Number(boundary.ordinal), timestamp);
782
+ const working = this.#workingItems(parentAgentId);
783
+ if (working.length > 0)
784
+ this.#copyContextRange(parentAgentId, agentId, Math.max(...working.map((item) => Number(item.ordinal))), timestamp, undefined, true);
722
785
  }
723
786
  else if (input.forkContext === 'compact' && input.compactSummary !== undefined) {
724
- this.#insertStandaloneContextMessage(agentId, 'system', input.compactSummary, 'compaction', timestamp);
787
+ this.#insertStandaloneContextMessage(agentId, 'assistant', input.compactSummary, 'compaction', timestamp);
788
+ const checkpoint = this.#one("SELECT id FROM context_items WHERE agent_id = ? AND kind = 'compaction'", agentId);
789
+ this.#database.prepare('INSERT INTO context_compactions(context_item_id, source_revision, covered_item_ids_json, focus) VALUES (?, ?, ?, ?)')
790
+ .run(checkpoint.id, Number(parent.context_revision), json(input.compactCoveredItemIds ?? []), input.compactFocus ?? null);
791
+ const retained = input.compactRetainedItemIds ?? [];
792
+ if (retained.length > 0) {
793
+ const through = Math.max(...retained.map((itemId) => Number(this.#require('context_items', itemId).ordinal)));
794
+ this.#copyContextRange(parentAgentId, agentId, through, timestamp, retained);
795
+ }
725
796
  }
726
797
  if (input.initialPrompt !== undefined)
727
798
  this.#insertStandaloneContextMessage(agentId, 'system', input.initialPrompt, 'message', timestamp);
@@ -807,10 +878,7 @@ export class Store {
807
878
  return replay;
808
879
  const ids = messageIdsForWrite(this.#activeRunId(targetAgentId));
809
880
  return this.#write(projectId, refs(...messageRefs(ids)), () => {
810
- const target = this.#require('agents', targetAgentId);
811
881
  this.#ownedAgent(parentAgentId, projectId);
812
- if (target.parent_agent_id !== parentAgentId)
813
- fail('AGENT_NOT_FOUND', 404, 'The target is not a direct child Agent.');
814
882
  const response = this.#insertQueuedMessage(projectId, targetAgentId, content, parentAgentId, ids, now());
815
883
  this.#recordIngress(ingress, response);
816
884
  return response;
@@ -835,32 +903,53 @@ export class Store {
835
903
  return response;
836
904
  });
837
905
  }
838
- async waitForSubagent(projectId, sourceAgentId, targetAgentId, timeoutMs, signal) {
906
+ async waitForSubagent(projectId, sourceAgentId, targetAgentId, timeoutMs, signal, afterMessageId) {
839
907
  const source = this.getAgent(projectId, sourceAgentId);
840
908
  const target = this.getAgent(projectId, targetAgentId);
841
- if (source.task_id !== target.task_id || target.parent_agent_id === null)
842
- fail('SUBAGENT_TARGET_INVALID', 400, 'The target must be a Subagent in the same Task.');
843
- const deadline = Date.now() + Math.min(timeoutMs, 120000);
844
- for (;;) {
845
- signal.throwIfAborted();
846
- const pending = this.#database.prepare(`SELECT * FROM pending_messages WHERE agent_id = ? AND source_agent_id = ? ORDER BY ordinal, id LIMIT 1`)
847
- .get(sourceAgentId, targetAgentId);
848
- if (pending !== undefined) {
909
+ if (source.session_id !== target.session_id || sourceAgentId === targetAgentId)
910
+ fail('SUBAGENT_TARGET_INVALID', 400, 'The target must be another Agent in the same Session.');
911
+ let after = 0;
912
+ if (afterMessageId !== undefined) {
913
+ const cursor = this.#database.prepare('SELECT rowid FROM messages WHERE id = ? AND agent_id = ? AND source_agent_id = ?').get(afterMessageId, sourceAgentId, targetAgentId);
914
+ if (cursor === undefined)
915
+ fail('INVALID_INPUT', 400, 'The message cursor must identify a message from the target to this Agent.');
916
+ after = Number(cursor.rowid);
917
+ }
918
+ return this.#events.waitFor(projectId, () => {
919
+ const message = this.#database.prepare(`SELECT * FROM messages WHERE agent_id = ? AND source_agent_id = ? AND rowid > ?
920
+ AND COALESCE(json_extract(metadata_json, '$.context_copy'), 0) = 0 ORDER BY rowid LIMIT 1`)
921
+ .get(sourceAgentId, targetAgentId, after);
922
+ if (message !== undefined) {
923
+ const pending = this.#database.prepare('SELECT * FROM pending_messages WHERE message_id = ?').get(message.id);
849
924
  return {
850
925
  status: 'message',
851
926
  target_agent_id: targetAgentId,
852
- pending_message: doc(pending),
853
- message: this.getMessage(projectId, String(pending.message_id))
927
+ pending_message: pending === undefined ? null : doc(pending),
928
+ message: this.getMessage(projectId, String(message.id)),
929
+ after_message_id: String(message.id)
854
930
  };
855
931
  }
856
932
  const activeRunIds = this.#all(`SELECT id FROM runs WHERE agent_id = ? AND state IN ('READY', 'RUNNING', 'WAITING') ORDER BY created_at, id`, targetAgentId)
857
933
  .map((row) => String(row.id));
858
- if (activeRunIds.length === 0)
859
- return { status: 'completed', target_agent_id: targetAgentId, active_run_ids: [] };
860
- if (Date.now() >= deadline)
861
- fail('CALL_TIMEOUT', 408, 'The Subagent wait timed out.', { target_agent_id: targetAgentId });
862
- await new Promise((resolve) => setTimeout(resolve, Math.min(50, Math.max(1, deadline - Date.now()))));
863
- }
934
+ if (activeRunIds.length === 0) {
935
+ const run = this.#database.prepare('SELECT * FROM runs WHERE agent_id = ? ORDER BY rowid DESC LIMIT 1').get(targetAgentId);
936
+ const answer = run === undefined ? undefined : this.#database.prepare(`SELECT m.id FROM messages m JOIN context_items ci ON ci.source_id = m.id AND ci.kind = 'message'
937
+ WHERE ci.agent_id = ? AND m.role = 'assistant' AND m.source_agent_id IS NULL
938
+ AND ci.turn_group_id IN (SELECT turn_group_id FROM steps WHERE run_id = ?)
939
+ ORDER BY ci.ordinal DESC LIMIT 1`).get(targetAgentId, run.id);
940
+ return {
941
+ status: run === undefined ? 'idle' : run.outcome === 'success' ? 'completed' : String(run.outcome),
942
+ target_agent_id: targetAgentId,
943
+ active_run_ids: [],
944
+ run: run === undefined ? null : this.getRun(projectId, String(run.id)),
945
+ message: answer === undefined ? null : this.getMessage(projectId, String(answer.id))
946
+ };
947
+ }
948
+ return undefined;
949
+ }, signal, {
950
+ ms: Math.min(timeoutMs, 120000),
951
+ error: new LensError('CALL_TIMEOUT', 408, 'The Subagent wait timed out.', { target_agent_id: targetAgentId })
952
+ });
864
953
  }
865
954
  listPending(projectId, agentId, options) {
866
955
  this.getAgent(projectId, agentId);
@@ -880,9 +969,10 @@ export class Store {
880
969
  this.#database.prepare('UPDATE pending_messages SET content = ?, revision = revision + 1, updated_at = ? WHERE id = ?')
881
970
  .run(content, now(), pendingId);
882
971
  this.#database.prepare('UPDATE messages SET content = ? WHERE id = ?').run(content, current.message_id);
883
- this.#database.prepare(`UPDATE context_items SET content = ?, revision = revision + 1, updated_at = ? WHERE agent_id = ? AND kind = 'message' AND source_id = ?`)
972
+ const updated = this.#database.prepare(`UPDATE context_items SET content = ?, revision = revision + 1, updated_at = ? WHERE agent_id = ? AND kind = 'message' AND source_id = ?`)
884
973
  .run(content, now(), agentId, current.message_id);
885
- this.#touchContext(agentId);
974
+ if (updated.changes > 0)
975
+ this.#touchContext(agentId);
886
976
  return this.getPending(projectId, pendingId);
887
977
  });
888
978
  }
@@ -896,6 +986,10 @@ export class Store {
896
986
  this.#database.prepare(`UPDATE context_turn_groups SET state = 'ABORTED', revision = revision + 1, completed_at = ? WHERE id IN (SELECT turn_group_id FROM context_items WHERE agent_id = ? AND kind = 'message' AND source_id = ?) AND state = 'OPEN'`)
897
987
  .run(timestamp, agentId, current.message_id);
898
988
  this.#database.prepare('DELETE FROM pending_messages WHERE id = ?').run(pendingId);
989
+ const updated = this.#database.prepare(`UPDATE context_items SET visible = 0, revision = revision + 1, updated_at = ? WHERE agent_id = ? AND kind = 'message' AND source_id = ?`)
990
+ .run(timestamp, agentId, current.message_id);
991
+ if (updated.changes > 0)
992
+ this.#touchContext(agentId);
899
993
  if (this.#count('SELECT COUNT(*) AS count FROM pending_messages WHERE agent_id = ?', agentId) === 0) {
900
994
  this.#database.prepare(`UPDATE runs SET state = 'TERMINAL', outcome = 'cancelled', error_code = 'PENDING_DELETED', revision = revision + 1, updated_at = ? WHERE agent_id = ? AND state = 'READY' AND initial_step_pending = 0`)
901
995
  .run(timestamp, agentId);
@@ -909,7 +1003,8 @@ export class Store {
909
1003
  if (rows.length !== ids.length || rows.some((entry) => !ids.includes(entry)))
910
1004
  fail('PENDING_SET_MISMATCH', 409, 'The pending Message set changed.');
911
1005
  const temporary = this.#database.prepare('UPDATE pending_messages SET ordinal = ?, revision = revision + 1, updated_at = ? WHERE id = ?');
912
- ids.forEach((entry, ordinal) => temporary.run(ordinal + ids.length, now(), entry));
1006
+ const offset = this.#nextOrdinal('pending_messages', 'agent_id', agentId);
1007
+ ids.forEach((entry, ordinal) => temporary.run(ordinal + offset, now(), entry));
913
1008
  ids.forEach((entry, ordinal) => temporary.run(ordinal, now(), entry));
914
1009
  return this.#all('SELECT * FROM pending_messages WHERE agent_id = ? ORDER BY ordinal', agentId).map(doc);
915
1010
  });
@@ -1057,23 +1152,12 @@ export class Store {
1057
1152
  const runId = String(ready.id);
1058
1153
  const stepId = id('step');
1059
1154
  const pending = this.#all('SELECT * FROM pending_messages WHERE agent_id = ? ORDER BY ordinal, id', agentId);
1060
- return this.#write(projectId, refs(['run', runId], ['step', stepId], ['agent', agentId]), () => {
1155
+ const changes = [...refs(['run', runId], ['step', stepId], ['agent', agentId])];
1156
+ return this.#write(projectId, changes, () => {
1061
1157
  const timestamp = now();
1062
- let turnGroupId;
1063
- if (pending.length === 0) {
1064
- turnGroupId = id('turn');
1065
- this.#insertTurnGroup(turnGroupId, agentId, 'turn', 'OPEN', timestamp);
1066
- }
1067
- else {
1068
- const items = pending.map((entry) => this.#one(`SELECT * FROM context_items WHERE agent_id = ? AND kind = 'message' AND source_id = ? ORDER BY ordinal DESC LIMIT 1`, agentId, entry.message_id));
1069
- turnGroupId = String(items[0]?.turn_group_id);
1070
- items.forEach((item, ordinal) => {
1071
- const sourceGroupId = String(item.turn_group_id);
1072
- this.#database.prepare('UPDATE context_items SET turn_group_id = ?, turn_group_ordinal = ? WHERE id = ?').run(turnGroupId, ordinal, item.id);
1073
- if (sourceGroupId !== turnGroupId)
1074
- this.#database.prepare('DELETE FROM context_turn_groups WHERE id = ?').run(sourceGroupId);
1075
- });
1076
- }
1158
+ const turnGroupId = id('turn');
1159
+ this.#insertTurnGroup(turnGroupId, agentId, 'turn', 'OPEN', timestamp);
1160
+ changes.push({ type: 'turn_group', id: turnGroupId }, ...this.#appendPendingContext(agentId, turnGroupId, pending, timestamp));
1077
1161
  const goals = strings(parseJson(String(ready.goal_message_ids_json)));
1078
1162
  const goalMessageIds = [...new Set([...goals, ...pending.map((entry) => String(entry.message_id))])];
1079
1163
  const update = this.#database.prepare(`UPDATE runs SET state = 'RUNNING', goal_message_ids_json = ?, initial_step_pending = 0, revision = revision + 1, updated_at = ? WHERE id = ? AND state = 'READY'`)
@@ -1134,6 +1218,7 @@ export class Store {
1134
1218
  const sequence = this.#count('SELECT COUNT(*) AS count FROM model_attempts WHERE agent_id = ? AND kind = ?', input.agentId, input.kind);
1135
1219
  this.#database.prepare(`INSERT INTO model_attempts(id, agent_id, step_id, kind, sequence, model, state, outcome, profile_id, profile_version, profile_digest, config_digest, context_revision, request_digest, preparation_json, usage_json, error_code, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, 'PREPARED', NULL, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, ?, ?)`)
1136
1220
  .run(attemptId, input.agentId, input.stepId, input.kind, sequence, input.model, input.profileId, input.profileVersion, input.profileDigest, input.configDigest, input.contextRevision, digest(input.request), json(input.preparation), timestamp, timestamp);
1221
+ this.#database.prepare('UPDATE model_attempts SET request_json = ? WHERE id = ?').run(json(input.request), attemptId);
1137
1222
  const statement = this.#database.prepare('INSERT INTO attempt_capabilities(attempt_id, ordinal, capability_id, schema_json) VALUES (?, ?, ?, ?)');
1138
1223
  input.capabilities.forEach((entry, ordinal) => statement.run(attemptId, ordinal, entry.id, json(entry.schema)));
1139
1224
  return this.getAttempt(projectId, attemptId);
@@ -1149,9 +1234,28 @@ export class Store {
1149
1234
  this.getAgent(projectId, ownerId);
1150
1235
  else
1151
1236
  this.getStep(projectId, ownerId);
1152
- return this.#page(`SELECT * FROM model_attempts WHERE ${field} = ?`, [ownerId], options, 'sequence DESC, id DESC');
1237
+ return this.#page(`SELECT * FROM model_attempts WHERE ${field} = ?`, [ownerId], options, 'rowid DESC');
1238
+ }
1239
+ latestModelAttempt(projectId, agentId, kind, model) {
1240
+ this.getAgent(projectId, agentId);
1241
+ const row = this.#database.prepare(`SELECT * FROM model_attempts WHERE agent_id = ? AND state != 'PREPARED' ${kind === undefined ? '' : 'AND kind = ?'} ${model === undefined ? '' : 'AND model = ?'} ORDER BY rowid DESC LIMIT 1`)
1242
+ .get(agentId, ...(kind === undefined ? [] : [kind]), ...(model === undefined ? [] : [model]));
1243
+ return row === undefined ? null : doc(row);
1153
1244
  }
1154
- transitionAttempt(projectId, attemptId, from, to, outcome = null, usage = null, errorCode = null) {
1245
+ cacheUsage(projectId, agentId) {
1246
+ this.getAgent(projectId, agentId);
1247
+ const row = this.#one(`SELECT COUNT(*) AS attempts,
1248
+ SUM(CASE WHEN json_type(usage_json, '$.cache_read_tokens') = 'integer' THEN 1 ELSE 0 END) AS reported_attempts,
1249
+ SUM(CASE WHEN json_type(usage_json, '$.cache_read_tokens') = 'integer' THEN json_extract(usage_json, '$.input_tokens') ELSE 0 END) AS reported_input_tokens,
1250
+ SUM(json_extract(usage_json, '$.cache_read_tokens')) AS cache_read_tokens,
1251
+ SUM(json_extract(usage_json, '$.cache_write_tokens')) AS cache_write_tokens,
1252
+ SUM(json_extract(usage_json, '$.uncached_input_tokens')) AS uncached_input_tokens
1253
+ FROM model_attempts WHERE agent_id = ?`, agentId);
1254
+ const denominator = Number(row.reported_input_tokens ?? 0);
1255
+ return { ...doc(row), unknown_attempts: Number(row.attempts) - Number(row.reported_attempts ?? 0),
1256
+ reported_hit_ratio: denominator > 0 ? Number(row.cache_read_tokens) / denominator : null };
1257
+ }
1258
+ transitionAttempt(projectId, attemptId, from, to, outcome = null, usage = null, errorCode = null, observation = null) {
1155
1259
  return this.#write(projectId, refs(['model_attempt', attemptId]), () => {
1156
1260
  const current = this.#require('model_attempts', attemptId);
1157
1261
  this.#ownedAgent(String(current.agent_id), projectId);
@@ -1159,6 +1263,8 @@ export class Store {
1159
1263
  .run(to, outcome, usage === null ? null : json(usage), errorCode, now(), attemptId, from);
1160
1264
  if (result.changes !== 1)
1161
1265
  fail('ATTEMPT_STATE_CONFLICT', 409, 'The ModelAttempt state changed.');
1266
+ if (observation !== null)
1267
+ this.#database.prepare('UPDATE model_attempts SET observation_json = ? WHERE id = ?').run(json(observation), attemptId);
1162
1268
  return this.getAttempt(projectId, attemptId);
1163
1269
  });
1164
1270
  }
@@ -1197,8 +1303,8 @@ export class Store {
1197
1303
  this.getStep(projectId, input.stepId);
1198
1304
  if (input.attemptId !== undefined)
1199
1305
  this.getAttempt(projectId, input.attemptId);
1200
- this.#database.prepare(`INSERT INTO calls(id, project_id, workspace_id, agent_id, step_id, attempt_id, partition_id, parent_call_id, source, capability_id, provider_call_id, call_ordinal, input_json, effect, policy, state, wait_reason, outcome, outcome_unknown, auto_review_passed, revision, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, 0, ?, 0, ?, ?)`)
1201
- .run(callId, projectId, input.workspaceId, input.agentId ?? null, input.stepId ?? null, input.attemptId ?? null, input.partitionId ?? null, input.parentCallId ?? null, input.source, input.capabilityId, input.providerCallId ?? null, input.callOrdinal ?? null, json(input.arguments), input.effect, input.policy ?? null, input.state, input.waitReason ?? null, input.autoReviewPassed === undefined ? null : input.autoReviewPassed ? 1 : 0, timestamp, timestamp);
1306
+ this.#database.prepare(`INSERT INTO calls(id, project_id, workspace_id, agent_id, step_id, attempt_id, partition_id, parent_call_id, source, capability_id, provider_call_id, call_ordinal, input_json, continuation_json, effect, policy, state, wait_reason, outcome, outcome_unknown, auto_review_passed, revision, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, 0, ?, 0, ?, ?)`)
1307
+ .run(callId, projectId, input.workspaceId, input.agentId ?? null, input.stepId ?? null, input.attemptId ?? null, input.partitionId ?? null, input.parentCallId ?? null, input.source, input.capabilityId, input.providerCallId ?? null, input.callOrdinal ?? null, json(input.arguments), json(input.prompt === undefined ? {} : { prompt: input.prompt }), input.effect, input.policy ?? null, input.state, input.waitReason ?? null, input.autoReviewPassed === undefined ? null : input.autoReviewPassed ? 1 : 0, timestamp, timestamp);
1202
1308
  const call = this.getCall(projectId, callId);
1203
1309
  if (input.ingress !== undefined)
1204
1310
  this.#recordIngress(input.ingress, call);
@@ -1236,15 +1342,12 @@ export class Store {
1236
1342
  const call = this.#callRow(projectId, callId);
1237
1343
  const result = this.#database.prepare('SELECT id FROM results WHERE call_id = ?').get(callId);
1238
1344
  const summary = this.#callSummaries([call])[0] ?? {};
1239
- const input = jsonRecord(call.input);
1240
1345
  return {
1241
1346
  id: call.id ?? null,
1242
1347
  call_id: call.id ?? null,
1243
1348
  summary,
1244
1349
  provider_call_id: call.provider_call_id ?? null,
1245
- // `input_json` holds the arguments directly, except once a user answer has been merged in, when
1246
- // it becomes `{ arguments, answer }` -- the same test `answerCall` makes before rewriting it.
1247
- arguments: input !== null && 'arguments' in input ? input.arguments ?? null : call.input ?? null,
1350
+ arguments: call.input ?? null,
1248
1351
  user_prompt: summary.user_prompt ?? null,
1249
1352
  result_id: result === undefined ? null : String(result.id),
1250
1353
  agent_id: call.agent_id ?? null
@@ -1286,7 +1389,8 @@ export class Store {
1286
1389
  wait_reason: call.wait_reason ?? null,
1287
1390
  legacy_state: legacyCallState(call),
1288
1391
  auto_review_passed: call.auto_review_passed ?? null,
1289
- user_prompt: jsonRecord(jsonRecord(call.input)?.prompt),
1392
+ auto_review: call.auto_review ?? null,
1393
+ user_prompt: jsonRecord(jsonRecord(call.continuation)?.prompt),
1290
1394
  outcome: call.outcome ?? null,
1291
1395
  outcome_unknown: call.outcome_unknown === true,
1292
1396
  yielded_at: null,
@@ -1349,13 +1453,13 @@ export class Store {
1349
1453
  this.#expect(current, expectedRevision);
1350
1454
  const state = String(current.state);
1351
1455
  if (decision !== 'cancel') {
1352
- const prompt = jsonRecord(jsonRecord(parseJson(String(current.input_json)))?.prompt);
1456
+ const prompt = jsonRecord(jsonRecord(doc(current).continuation)?.prompt);
1353
1457
  if (state !== 'WAITING' || current.wait_reason !== 'user_input' || prompt?.kind !== 'policy_approval') {
1354
1458
  fail('CALL_STATE_CONFLICT', 409, 'The Call is not waiting for a policy decision.');
1355
1459
  }
1356
1460
  }
1357
1461
  if (decision === 'approve') {
1358
- this.#database.prepare(`UPDATE calls SET state = 'QUEUED', wait_reason = NULL, revision = revision + 1, updated_at = ? WHERE id = ?`).run(now(), callId);
1462
+ this.#database.prepare(`UPDATE calls SET state = 'QUEUED', wait_reason = NULL, continuation_json = '{}', revision = revision + 1, updated_at = ? WHERE id = ?`).run(now(), callId);
1359
1463
  return { call: this.getCall(projectId, callId), result: null };
1360
1464
  }
1361
1465
  if (state === 'TERMINAL')
@@ -1376,8 +1480,8 @@ export class Store {
1376
1480
  this.#expect(current, expectedRevision);
1377
1481
  if (current.state !== 'WAITING' || current.wait_reason !== 'user_input')
1378
1482
  fail('CALL_STATE_CONFLICT', 409, 'The Call is not waiting for user input.');
1379
- const input = parseJson(String(current.input_json));
1380
- const prompt = jsonRecord(input)?.prompt;
1483
+ const continuation = jsonRecord(doc(current).continuation);
1484
+ const prompt = continuation?.prompt;
1381
1485
  validateAnswer(jsonRecord(prompt), answer);
1382
1486
  if (jsonRecord(prompt)?.kind === 'policy_approval' && policyAnswer(answer) === 'reject') {
1383
1487
  const resultId = id('result');
@@ -1387,46 +1491,209 @@ export class Store {
1387
1491
  .run(resultId, callId, timestamp);
1388
1492
  return { call: this.getCall(projectId, callId), result: doc(this.#require('results', resultId)) };
1389
1493
  }
1390
- const nextInput = typeof input === 'object' && input !== null && !Array.isArray(input) && 'arguments' in input
1391
- ? { ...input, answer }
1392
- : { arguments: input, answer };
1393
- this.#database.prepare(`UPDATE calls SET input_json = ?, state = 'QUEUED', wait_reason = NULL, revision = revision + 1, updated_at = ? WHERE id = ?`)
1394
- .run(json(nextInput), now(), callId);
1494
+ const nextContinuation = jsonRecord(prompt)?.kind === 'policy_approval' ? {} : { ...continuation, answer };
1495
+ this.#database.prepare(`UPDATE calls SET continuation_json = ?, state = 'QUEUED', wait_reason = NULL, revision = revision + 1, updated_at = ? WHERE id = ?`)
1496
+ .run(json(nextContinuation), now(), callId);
1395
1497
  return { call: this.getCall(projectId, callId), result: null };
1396
1498
  });
1397
1499
  }
1398
- reviewCall(projectId, callId, approved) {
1500
+ reviewCall(projectId, callId, review) {
1399
1501
  return this.#write(projectId, refs(['call', callId]), () => {
1400
1502
  const current = this.#require('calls', callId);
1401
1503
  owned(current, 'project_id', projectId);
1402
1504
  if (current.state !== 'WAITING' || current.wait_reason !== 'policy_review')
1403
1505
  fail('CALL_STATE_CONFLICT', 409, 'The Call is not waiting for policy review.');
1404
- const input = parseJson(String(current.input_json));
1405
- this.#database.prepare(`UPDATE calls SET input_json = ?, state = ?, wait_reason = ?, auto_review_passed = ?, revision = revision + 1, updated_at = ? WHERE id = ?`)
1406
- .run(approved ? json(input) : json({ arguments: input, prompt: policyPrompt(String(current.capability_id)) }), approved ? 'QUEUED' : 'WAITING', approved ? null : 'user_input', approved ? 1 : 0, now(), callId);
1506
+ const approved = review.status === 'reviewed' && review.outcome === 'allow';
1507
+ this.#database.prepare(`UPDATE calls SET continuation_json = ?, state = ?, wait_reason = ?, auto_review_passed = ?, auto_review_json = ?, revision = revision + 1, updated_at = ? WHERE id = ?`)
1508
+ .run(approved ? json({}) : json({ prompt: policyPrompt(String(current.capability_id), review.rationale) }), approved ? 'QUEUED' : 'WAITING', approved ? null : 'user_input', review.status === 'failed' ? null : approved ? 1 : 0, json(review), now(), callId);
1509
+ return this.getCall(projectId, callId);
1510
+ });
1511
+ }
1512
+ autoReviewContext(projectId, agentId) {
1513
+ const agent = this.getAgent(projectId, agentId);
1514
+ const authorization = [];
1515
+ let ancestor = agent;
1516
+ while (ancestor !== null) {
1517
+ // Read original messages, including compacted ones. Copied/delegated text and deleted
1518
+ // pending messages must not turn into user permission merely because their role is user.
1519
+ authorization.push(...this.#all(`SELECT m.id, m.agent_id, m.content, m.created_at, m.rowid AS sequence,
1520
+ COALESCE(json_extract(m.metadata_json, '$.ingress_origin'), 'unknown') AS ingress_origin
1521
+ FROM messages m WHERE m.agent_id = ? AND m.role = 'user' AND m.source_agent_id IS NULL
1522
+ AND (m.metadata_json IS NULL OR json_extract(m.metadata_json, '$.ingress_origin') = 'user')
1523
+ AND (EXISTS (SELECT 1 FROM pending_messages p WHERE p.message_id = m.id)
1524
+ OR EXISTS (SELECT 1 FROM context_items ci JOIN context_turn_groups g ON g.id = ci.turn_group_id
1525
+ WHERE ci.source_id = m.id AND ci.kind = 'message' AND g.state != 'ABORTED'))
1526
+ ORDER BY m.rowid`, stringField(ancestor.id)).map(doc));
1527
+ ancestor = typeof ancestor.parent_agent_id === 'string' ? this.getAgent(projectId, ancestor.parent_agent_id) : null;
1528
+ }
1529
+ authorization.sort((a, b) => Number(a.sequence) - Number(b.sequence));
1530
+ const evidence = this.#all(`SELECT ci.role, ci.kind, ci.tool_name,
1531
+ substr(ci.content, 1, 1000) AS excerpt, CASE WHEN length(ci.content) > 1000 THEN 1 ELSE 0 END AS truncated
1532
+ FROM context_items ci JOIN context_turn_groups g ON g.id = ci.turn_group_id
1533
+ WHERE ci.agent_id = ? AND ci.visible = 1 AND g.state != 'ABORTED'
1534
+ ORDER BY ci.ordinal DESC LIMIT 12`, agentId).map(doc).reverse();
1535
+ return { authorization, workspacePaths: this.projectWorkspacePaths(projectId, stringField(agent.workspace_id)), evidence };
1536
+ }
1537
+ consumePendingForStep(projectId, stepId) {
1538
+ const step = this.getStep(projectId, stepId);
1539
+ const run = this.getRun(projectId, stringField(step.run_id));
1540
+ const agentId = stringField(run.agent_id);
1541
+ const pending = this.#all('SELECT * FROM pending_messages WHERE agent_id = ? ORDER BY ordinal, id', agentId);
1542
+ if (pending.length === 0)
1543
+ return [];
1544
+ const changes = [...refs(['agent', agentId], ['run', stringField(run.id)], ...pending.map(entry => ['pending_message', String(entry.id)]))];
1545
+ return this.#write(projectId, changes, () => {
1546
+ if (step.state === 'TERMINAL' || run.state === 'TERMINAL')
1547
+ fail('RUN_STATE_CONFLICT', 409, 'The Run no longer accepts pending input.');
1548
+ this.#assertContext(agentId, false);
1549
+ const timestamp = now();
1550
+ changes.push(...this.#appendPendingContext(agentId, stringField(step.turn_group_id), pending, timestamp));
1551
+ const goals = [...new Set([...strings(run.goal_message_ids ?? []), ...pending.map(entry => String(entry.message_id))])];
1552
+ this.#database.prepare('UPDATE runs SET goal_message_ids_json = ?, revision = revision + 1, updated_at = ? WHERE id = ?')
1553
+ .run(json(goals), timestamp, run.id);
1554
+ const remove = this.#database.prepare('DELETE FROM pending_messages WHERE id = ?');
1555
+ for (const entry of pending)
1556
+ remove.run(entry.id);
1557
+ return pending.map(doc);
1558
+ });
1559
+ }
1560
+ #appendPendingContext(agentId, turnGroupId, pending, timestamp) {
1561
+ const changes = [];
1562
+ for (const entry of pending) {
1563
+ const item = this.#database.prepare(`SELECT * FROM context_items WHERE agent_id = ? AND kind = 'message' AND source_id = ?`).get(agentId, entry.message_id);
1564
+ // claimRun has already placed its initial batch in this group and queue order.
1565
+ if (item?.turn_group_id === turnGroupId)
1566
+ continue;
1567
+ const contextId = item === undefined ? id('context') : String(item.id);
1568
+ const ordinal = this.#nextOrdinal('context_items', 'agent_id', agentId);
1569
+ const groupOrdinal = this.#nextOrdinal('context_items', 'turn_group_id', turnGroupId);
1570
+ if (item === undefined) {
1571
+ const message = this.#require('messages', String(entry.message_id));
1572
+ this.#database.prepare(`INSERT INTO context_items(id, agent_id, turn_group_id, turn_group_ordinal, kind, source_id, role, content, ordinal, visible, locked, revision, created_at, updated_at, metadata_json) VALUES (?, ?, ?, ?, 'message', ?, 'user', ?, ?, 1, 0, 0, ?, ?, ?)`)
1573
+ .run(contextId, agentId, turnGroupId, groupOrdinal, entry.message_id, entry.content, ordinal, timestamp, timestamp, message.metadata_json);
1574
+ }
1575
+ else {
1576
+ this.#database.prepare('UPDATE context_items SET turn_group_id = ?, turn_group_ordinal = ?, ordinal = ?, visible = 1, revision = revision + 1, updated_at = ? WHERE id = ?')
1577
+ .run(turnGroupId, groupOrdinal, ordinal, timestamp, item.id);
1578
+ if (this.#count('SELECT COUNT(*) AS count FROM context_items WHERE turn_group_id = ?', item.turn_group_id) === 0) {
1579
+ this.#database.prepare('DELETE FROM context_turn_groups WHERE id = ? AND NOT EXISTS (SELECT 1 FROM steps WHERE turn_group_id = ?)').run(item.turn_group_id, item.turn_group_id);
1580
+ changes.push({ type: 'turn_group', id: String(item.turn_group_id) });
1581
+ }
1582
+ }
1583
+ changes.push({ type: 'context_item', id: contextId });
1584
+ }
1585
+ if (changes.length > 0)
1586
+ this.#touchContext(agentId);
1587
+ return changes;
1588
+ }
1589
+ interruptCall(projectId, callId, errorCode, message) {
1590
+ const changes = [];
1591
+ return this.#write(projectId, changes, () => {
1592
+ const call = this.#require('calls', callId);
1593
+ owned(call, 'project_id', projectId);
1594
+ changes.push(...this.#interruptCall(call, errorCode, message, now()));
1407
1595
  return this.getCall(projectId, callId);
1408
1596
  });
1409
1597
  }
1410
- startCall(projectId, callId) {
1598
+ interruptProjectCalls(projectId) {
1599
+ const changes = [];
1600
+ this.#write(projectId, changes, () => {
1601
+ for (const call of this.#all("SELECT * FROM calls WHERE project_id = ? AND state != 'TERMINAL'", projectId)) {
1602
+ changes.push(...this.#interruptCall(call, 'PROCESS_INTERRUPTED', 'The runtime stopped; this Call will not be resumed.', now()));
1603
+ }
1604
+ });
1605
+ }
1606
+ #interruptCall(call, errorCode, message, timestamp) {
1607
+ if (call.state === 'TERMINAL')
1608
+ return [];
1609
+ const running = call.state === 'RUNNING';
1610
+ const outcome = errorCode === 'CALL_CANCELLED' || !running ? 'cancelled' : 'interrupted';
1611
+ const detail = running ? `${message} External side effects may already have occurred.` : message;
1612
+ this.#database.prepare("UPDATE calls SET state = 'TERMINAL', wait_reason = NULL, outcome = ?, outcome_unknown = ?, revision = revision + 1, updated_at = ? WHERE id = ?")
1613
+ .run(outcome, running ? 1 : 0, timestamp, call.id);
1614
+ const resultId = id('result');
1615
+ this.#database.prepare('INSERT OR IGNORE INTO results(id, call_id, outcome, content_json, error_code, error_message, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)')
1616
+ .run(resultId, call.id, outcome, json({ error_code: errorCode, error_message: detail, outcome_unknown: running }), errorCode, detail, timestamp);
1617
+ return [{ type: 'call', id: String(call.id) }, { type: 'result', id: resultId }];
1618
+ }
1619
+ callAdmission(projectId, callId) {
1620
+ return this.#callAdmission(projectId, this.#callRow(projectId, callId));
1621
+ }
1622
+ #callAdmission(projectId, call) {
1623
+ if (call.state !== 'QUEUED')
1624
+ return 'inactive';
1625
+ if (typeof call.parent_call_id === 'string') {
1626
+ const parent = this.#callRow(projectId, call.parent_call_id);
1627
+ if (parent.state !== 'RUNNING')
1628
+ fail('CALL_PARENT_INACTIVE', 409, 'The parent Call is no longer running.');
1629
+ if (call.source !== 'program' || parent.capability_id !== 'programmatic_tool_run' ||
1630
+ typeof call.agent_id !== 'string' || typeof call.step_id !== 'string' || typeof call.attempt_id !== 'string' ||
1631
+ parent.agent_id !== call.agent_id || parent.step_id !== call.step_id || parent.attempt_id !== call.attempt_id) {
1632
+ fail('CALL_PARENT_INVALID', 409, 'The nested Call does not belong to its program parent.');
1633
+ }
1634
+ const frozen = this.#database.prepare('SELECT 1 FROM attempt_capabilities WHERE attempt_id = ? AND capability_id = ?')
1635
+ .get(call.attempt_id, call.capability_id);
1636
+ if (frozen === undefined)
1637
+ fail('CAPABILITY_NOT_LOADED', 403, 'The nested Capability is outside the frozen model attempt.');
1638
+ const selected = jsonRecord(parent.input)?.capabilityIds;
1639
+ if (Array.isArray(selected) && !selected.includes(call.capability_id ?? null)) {
1640
+ fail('CAPABILITY_NOT_LOADED', 403, 'The nested Capability is outside the parent program selection.');
1641
+ }
1642
+ // Program dependencies are inside the parent; its unfinished top-level batch cannot gate a child.
1643
+ return 'ready';
1644
+ }
1645
+ if (typeof call.partition_id !== 'string')
1646
+ return 'ready';
1647
+ const partition = this.#require('call_partitions', call.partition_id);
1648
+ if (partition.step_id !== call.step_id)
1649
+ fail('CALL_PARTITION_INVALID', 409, 'The Call does not belong to its partition Step.');
1650
+ const predecessor = this.#database.prepare(`SELECT 1 FROM calls c JOIN call_partitions p ON p.id = c.partition_id
1651
+ WHERE p.step_id = ? AND p.ordinal < ? AND c.parent_call_id IS NULL
1652
+ AND c.attempt_id IS ? AND c.state != 'TERMINAL' LIMIT 1`)
1653
+ .get(partition.step_id, partition.ordinal, call.attempt_id ?? null);
1654
+ return predecessor === undefined ? 'ready' : 'blocked';
1655
+ }
1656
+ startCall(projectId, callId, validate) {
1411
1657
  return this.#write(projectId, refs(['call', callId]), () => {
1658
+ const call = this.#callRow(projectId, callId);
1659
+ const admission = this.#callAdmission(projectId, call);
1660
+ if (admission === 'blocked')
1661
+ fail('CALL_BLOCKED', 409, 'An earlier top-level partition has not finished.');
1662
+ if (admission === 'inactive')
1663
+ fail('CALL_STATE_CONFLICT', 409, 'The Call is not queued.');
1664
+ validate?.(call);
1412
1665
  const result = this.#database.prepare(`UPDATE calls SET state = 'RUNNING', revision = revision + 1, updated_at = ? WHERE id = ? AND state = 'QUEUED'`).run(now(), callId);
1413
1666
  if (result.changes !== 1)
1414
1667
  fail('CALL_STATE_CONFLICT', 409, 'The Call is not queued.');
1415
1668
  return this.getCall(projectId, callId);
1416
1669
  });
1417
1670
  }
1671
+ failQueuedCall(projectId, callId, errorCode, errorMessage, content = {}) {
1672
+ const resultId = id('result');
1673
+ const timestamp = now();
1674
+ return this.#write(projectId, refs(['call', callId], ['result', resultId]), () => {
1675
+ this.#callRow(projectId, callId);
1676
+ const update = this.#database.prepare(`UPDATE calls SET state = 'TERMINAL', outcome = 'failed', wait_reason = NULL,
1677
+ outcome_unknown = 0, revision = revision + 1, updated_at = ? WHERE id = ? AND state = 'QUEUED'`).run(timestamp, callId);
1678
+ if (update.changes !== 1)
1679
+ fail('CALL_STATE_CONFLICT', 409, 'Only a queued Call can fail without executing.');
1680
+ this.#database.prepare(`INSERT INTO results(id, call_id, outcome, content_json, error_code, error_message, created_at)
1681
+ VALUES (?, ?, 'failed', ?, ?, ?, ?)`).run(resultId, callId, json(content), errorCode, errorMessage, timestamp);
1682
+ return { call: this.getCall(projectId, callId), result: doc(this.#require('results', resultId)) };
1683
+ });
1684
+ }
1418
1685
  waitCall(projectId, callId, prompt) {
1419
1686
  return this.#write(projectId, refs(['call', callId]), () => {
1420
1687
  const current = this.#require('calls', callId);
1421
1688
  owned(current, 'project_id', projectId);
1422
1689
  if (current.state !== 'RUNNING')
1423
1690
  fail('CALL_STATE_CONFLICT', 409, 'Only a running Call can request user input.');
1424
- this.#database.prepare(`UPDATE calls SET state = 'WAITING', wait_reason = 'user_input', input_json = ?, revision = revision + 1, updated_at = ? WHERE id = ?`)
1425
- .run(json({ arguments: parseJson(String(current.input_json)), prompt }), now(), callId);
1691
+ this.#database.prepare(`UPDATE calls SET state = 'WAITING', wait_reason = 'user_input', continuation_json = ?, revision = revision + 1, updated_at = ? WHERE id = ?`)
1692
+ .run(json({ prompt }), now(), callId);
1426
1693
  return this.getCall(projectId, callId);
1427
1694
  });
1428
1695
  }
1429
- completeCall(projectId, callId, outcome, content, errorCode = null, errorMessage = null, artifacts = [], truncated = false) {
1696
+ completeCall(projectId, callId, outcome, content, errorCode = null, errorMessage = null, artifacts = [], truncated = false, capture = 'unknown', stored = 'unknown') {
1430
1697
  const resultId = id('result');
1431
1698
  const timestamp = now();
1432
1699
  return this.#write(projectId, refs(['call', callId], ['result', resultId]), () => {
@@ -1436,6 +1703,7 @@ export class Store {
1436
1703
  fail('CALL_STATE_CONFLICT', 409, 'The Call is not running.');
1437
1704
  this.#database.prepare(`INSERT INTO results(id, call_id, outcome, content_json, truncated, error_code, error_message, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
1438
1705
  .run(resultId, callId, outcome, json(content), truncated ? 1 : 0, errorCode, errorMessage, timestamp);
1706
+ this.#database.prepare('UPDATE results SET capture = ?, stored = ? WHERE id = ?').run(capture, stored, resultId);
1439
1707
  const link = this.#database.prepare('INSERT INTO result_artifacts(result_id, artifact_id) VALUES (?, ?)');
1440
1708
  for (const artifactId of artifacts)
1441
1709
  link.run(resultId, artifactId);
@@ -1455,10 +1723,9 @@ export class Store {
1455
1723
  }
1456
1724
  /**
1457
1725
  * 02A `ResultDocument`. One `content_json` column becomes `text` or `structured` according to its
1458
- * JSON type, which is the same split `normalizeOutput` makes when it decides what to store: a
1459
- * Capability producing prose stores a string, one producing records stores an object or array, and
1460
- * either is `null` here when the payload was too large to inline and became an Artifact instead.
1461
- * That is why a client reading a large Result has to fall back to `artifact_ids` and ART-01.
1726
+ * JSON type. Short results retain their original type; long non-PTC results carry a text preview
1727
+ * and full-body Artifact. Earlier results may have null content with only an Artifact reference.
1728
+ * Clients reading the full large Result use `artifact_ids` and ART-01.
1462
1729
  *
1463
1730
  * `error_code` stays off the wire here because the documented shape does not carry it; a failed
1464
1731
  * Call reports its code on `CallSummary.error_code`.
@@ -1478,11 +1745,13 @@ export class Store {
1478
1745
  .map((artifact) => jsonRecord(artifact)?.id)
1479
1746
  .filter((value) => typeof value === 'string'),
1480
1747
  truncated: result.truncated === true,
1748
+ payload: { capture: result.capture ?? 'unknown', stored: result.stored ?? 'unknown', view: result.truncated === true ? 'preview' : 'full' },
1481
1749
  created_at: result.created_at ?? null
1482
1750
  };
1483
1751
  }
1484
1752
  includeResult(projectId, resultId, agentId, expectedContextRevision) {
1485
1753
  const result = this.getResult(projectId, resultId);
1754
+ const call = this.#require('calls', stringField(result.call_id));
1486
1755
  const contextId = id('context');
1487
1756
  const turnGroupId = id('turn');
1488
1757
  const timestamp = now();
@@ -1493,7 +1762,7 @@ export class Store {
1493
1762
  const ordinal = this.#nextOrdinal('context_items', 'agent_id', agentId);
1494
1763
  this.#insertTurnGroup(turnGroupId, agentId, 'included', 'COMPLETE', timestamp);
1495
1764
  this.#database.prepare(`INSERT INTO context_items(id, agent_id, turn_group_id, turn_group_ordinal, kind, source_id, role, content, ordinal, visible, locked, revision, created_at, updated_at) VALUES (?, ?, ?, 0, 'result', ?, 'tool', ?, ?, 1, 0, 0, ?, ?)`)
1496
- .run(contextId, agentId, turnGroupId, resultId, json({ content: result.content ?? null, artifacts: result.artifacts ?? [], truncated: result.truncated === true }), ordinal, timestamp, timestamp);
1765
+ .run(contextId, agentId, turnGroupId, resultId, this.#resultContextContent(call, result), ordinal, timestamp, timestamp);
1497
1766
  this.#touchContext(agentId);
1498
1767
  return this.getContextItem(projectId, contextId);
1499
1768
  });
@@ -1516,6 +1785,75 @@ export class Store {
1516
1785
  owned(row, 'project_id', projectId);
1517
1786
  return doc(row);
1518
1787
  }
1788
+ getAgentArtifact(projectId, agentId, artifactId) {
1789
+ this.getAgent(projectId, agentId);
1790
+ const artifact = this.getArtifact(projectId, artifactId);
1791
+ const linked = this.#database.prepare(`SELECT 1 FROM context_items ci
1792
+ JOIN result_artifacts ra ON ra.result_id = ci.source_id
1793
+ WHERE ci.agent_id = ? AND ci.kind = 'result' AND ci.visible = 1 AND ra.artifact_id = ? LIMIT 1`).get(agentId, artifactId);
1794
+ if (linked === undefined)
1795
+ fail('ARTIFACT_NOT_ACCESSIBLE', 404, 'Artifact is not referenced by this Agent visible history.');
1796
+ const captures = this.#all(`SELECT DISTINCT r.capture FROM results r JOIN result_artifacts ra ON ra.result_id = r.id
1797
+ JOIN context_items ci ON ci.source_id = r.id AND ci.kind = 'result'
1798
+ WHERE ci.agent_id = ? AND ci.visible = 1 AND ra.artifact_id = ?`, agentId, artifactId).map((row) => row.capture);
1799
+ return { ...artifact, capture: captures.includes('partial') ? 'partial' : captures.includes('unknown') ? 'unknown' : 'complete' };
1800
+ }
1801
+ readHistoryItem(projectId, agentId, contextId) {
1802
+ this.getAgent(projectId, agentId);
1803
+ const row = this.#database.prepare(`SELECT ci.*, CASE WHEN co.instructions IS NULL THEN ci.content
1804
+ ELSE co.instructions || char(10) || char(10) || ci.content END AS content
1805
+ , a.session_id, a.task_id, a.name AS agent_name, a.parent_agent_id
1806
+ FROM context_items ci JOIN agents a ON a.id = ci.agent_id JOIN tasks t ON t.id = a.task_id
1807
+ LEFT JOIN context_overrides co ON co.agent_id = ci.agent_id
1808
+ AND co.target_kind = ci.kind AND co.target_id = ci.source_id
1809
+ WHERE ci.id = ? AND t.project_id = ? AND ci.visible = 1`).get(contextId, projectId);
1810
+ if (row === undefined)
1811
+ fail('HISTORY_NOT_ACCESSIBLE', 404, 'History item is not visible in this Project.');
1812
+ return doc(row);
1813
+ }
1814
+ searchHistory(projectId, agentId, query, after, limit, scope = {}) {
1815
+ this.getAgent(projectId, agentId);
1816
+ if (scope.agent_id !== undefined)
1817
+ this.getAgent(projectId, scope.agent_id);
1818
+ if (scope.session_id !== undefined)
1819
+ this.getSession(projectId, scope.session_id);
1820
+ if (scope.task_id !== undefined)
1821
+ this.getTask(projectId, scope.task_id);
1822
+ const filters = ['t.project_id = ?', 'ci.visible = 1', 'ci.ordinal > ?'];
1823
+ const parameters = [projectId, after];
1824
+ for (const field of ['agent_id', 'session_id', 'task_id']) {
1825
+ const value = scope[field];
1826
+ if (value === undefined)
1827
+ continue;
1828
+ filters.push(`${field === 'agent_id' ? 'ci' : 'a'}.${field} = ?`);
1829
+ parameters.push(value);
1830
+ }
1831
+ if (scope.after_context_item_id !== undefined) {
1832
+ const cursor = this.readHistoryItem(projectId, agentId, scope.after_context_item_id);
1833
+ for (const field of ['agent_id', 'session_id', 'task_id']) {
1834
+ if (scope[field] !== undefined && scope[field] !== cursor[field])
1835
+ fail('INVALID_INPUT', 400, 'History continuation must use the same scope.');
1836
+ }
1837
+ const owner = this.getAgent(projectId, stringField(cursor.agent_id));
1838
+ filters.push('(a.created_at, a.id, ci.ordinal) > (?, ?, ?)');
1839
+ parameters.push(stringField(owner.created_at), stringField(cursor.agent_id), Number(cursor.ordinal));
1840
+ }
1841
+ const rows = this.#all(`WITH history AS (
1842
+ SELECT ci.id, ci.source_id, ci.agent_id, a.session_id, a.task_id, a.name AS agent_name, a.parent_agent_id,
1843
+ a.created_at AS agent_created_at, ci.ordinal, ci.role, ci.kind, ci.locked, ci.revision,
1844
+ CASE WHEN co.instructions IS NULL THEN ci.content ELSE co.instructions || char(10) || char(10) || ci.content END AS body
1845
+ FROM context_items ci JOIN agents a ON a.id = ci.agent_id JOIN tasks t ON t.id = a.task_id
1846
+ LEFT JOIN context_overrides co ON co.agent_id = ci.agent_id
1847
+ AND co.target_kind = ci.kind AND co.target_id = ci.source_id
1848
+ WHERE ${filters.join(' AND ')})
1849
+ SELECT id AS context_item_id, source_id, agent_id, session_id, task_id, agent_name, parent_agent_id, ordinal, role, kind, locked, revision,
1850
+ substr(body, max(1, instr(body, ?) - 50), 160) AS snippet
1851
+ FROM history WHERE ? = '' OR instr(body, ?) > 0 ORDER BY agent_created_at, agent_id, ordinal LIMIT ?`, ...parameters, query, query, query, limit + 1);
1852
+ const matches = rows.slice(0, limit).map(doc);
1853
+ return { matches, has_more: rows.length > limit,
1854
+ next_after_context_item_id: rows.length > limit ? matches.at(-1)?.context_item_id ?? null : null,
1855
+ next_after_ordinal: rows.length > limit && scope.agent_id !== undefined ? matches.at(-1)?.ordinal ?? null : null };
1856
+ }
1519
1857
  listArtifacts(projectId, options) {
1520
1858
  this.getProject(projectId);
1521
1859
  // Explicit order: the shared pager defaults to `updated_at`, which this table does not have
@@ -1534,27 +1872,100 @@ export class Store {
1534
1872
  const order = direction === 'backward' ? 'ordinal DESC, id DESC' : 'ordinal ASC, id ASC';
1535
1873
  return this.#page('SELECT * FROM context_items WHERE agent_id = ?', [agentId], options, order);
1536
1874
  }
1537
- contextSource(projectId, agentId) {
1875
+ contextSource(projectId, agentId, completedOnly = false) {
1876
+ const items = this.#all(`SELECT * FROM context_items WHERE agent_id = ? AND visible = 1 ${completedOnly ? "AND turn_group_id IN (SELECT id FROM context_turn_groups WHERE state = 'COMPLETE')" : ''} ORDER BY ordinal, id`, agentId).map(doc);
1877
+ const ids = new Set(items.map((item) => item.id));
1538
1878
  return {
1539
1879
  agent: this.getAgent(projectId, agentId),
1540
- items: this.#all('SELECT * FROM context_items WHERE agent_id = ? AND visible = 1 ORDER BY ordinal, id', agentId).map(doc),
1880
+ items,
1881
+ todo: this.getTodo(projectId, agentId),
1882
+ unsettledItemIds: this.#unsettledContextItems(agentId),
1883
+ workingItems: this.#workingItems(agentId).filter((item) => ids.has(item.id)),
1541
1884
  overrides: this.#all('SELECT * FROM context_overrides WHERE agent_id = ? ORDER BY created_at, id', agentId).map(doc),
1542
1885
  capabilities: this.listCapabilities(projectId, agentId).filter((entry) => entry.loaded === true)
1543
1886
  };
1544
1887
  }
1545
- getPlan(projectId, agentId) {
1888
+ workingContext(projectId, agentId) {
1889
+ this.getAgent(projectId, agentId);
1890
+ const state = this.#database.prepare('SELECT * FROM working_contexts WHERE agent_id = ?').get(agentId);
1891
+ return { ...(state === undefined ? {} : doc(state)), items: this.#workingItems(agentId) };
1892
+ }
1893
+ saveWorkingContext(projectId, agentId, sourceRevision, edits, messages, prefixes, requestFrame) {
1894
+ const state = this.workingContext(projectId, agentId);
1895
+ const messageJson = json(messages);
1896
+ if (edits.length === 0 && json(state.messages ?? null) === messageJson && json(state.request_frame ?? null) === json(requestFrame))
1897
+ return;
1898
+ this.#write(projectId, refs(['agent', agentId]), () => {
1899
+ if (this.getAgent(projectId, agentId).context_revision !== sourceRevision)
1900
+ fail('REVISION_CONFLICT', 409, 'Context changed before Working Context publication.');
1901
+ const update = this.#database.prepare('UPDATE working_context_items SET content = ?, snipped = 1 WHERE agent_id = ? AND context_item_id = ?');
1902
+ for (const edit of edits) {
1903
+ if (update.run(edit.content, agentId, edit.id).changes !== 1)
1904
+ fail('CONTEXT_ITEM_CONFLICT', 409, 'A Working Context source is no longer active.');
1905
+ }
1906
+ this.#database.prepare('UPDATE working_contexts SET revision = revision + 1, digest = ?, messages_json = ?, prefixes_json = ?, request_frame_json = ?, updated_at = ? WHERE agent_id = ?')
1907
+ .run(digest({ items: this.#workingItems(agentId), prefixes }), messageJson, json(prefixes), json(requestFrame), now(), agentId);
1908
+ });
1909
+ }
1910
+ compactionSources(projectId, contextItemId) {
1911
+ this.getContextItem(projectId, contextItemId);
1912
+ const row = this.#database.prepare('SELECT * FROM context_compactions WHERE context_item_id = ?').get(contextItemId);
1913
+ if (row === undefined)
1914
+ return { context_item_id: contextItemId, covered_item_ids: [], coverage_known: false };
1915
+ const record = doc(row);
1916
+ return { ...record, coverage_known: true, items: strings(record.covered_item_ids ?? []).map((itemId) => this.getContextItem(projectId, itemId)) };
1917
+ }
1918
+ getTodo(projectId, agentId) {
1546
1919
  this.getAgent(projectId, agentId);
1547
1920
  const row = this.#database.prepare('SELECT * FROM agent_plans WHERE agent_id = ?').get(agentId);
1548
1921
  return row === undefined ? null : doc(row);
1549
1922
  }
1550
- updatePlan(projectId, agentId, items) {
1923
+ updateTodo(projectId, agentId, items) {
1924
+ const current = this.getTodo(projectId, agentId);
1925
+ if (current !== null && digest(current.items ?? []) === digest(items))
1926
+ return current;
1551
1927
  return this.#write(projectId, refs(['agent_plan', agentId]), () => {
1552
1928
  this.getAgent(projectId, agentId);
1553
1929
  this.#database.prepare(`INSERT INTO agent_plans(agent_id, items_json, revision, updated_at) VALUES (?, ?, 0, ?) ON CONFLICT(agent_id) DO UPDATE SET items_json = excluded.items_json, revision = agent_plans.revision + 1, updated_at = excluded.updated_at`)
1554
1930
  .run(agentId, json(items), now());
1931
+ this.#touchContext(agentId);
1555
1932
  return doc(this.#one('SELECT * FROM agent_plans WHERE agent_id = ?', agentId));
1556
1933
  });
1557
1934
  }
1935
+ syncTodoReminder(projectId, agentId) {
1936
+ const source = this.contextSource(projectId, agentId);
1937
+ if (!source.capabilities.some((entry) => entry.capability_id === 'todo'))
1938
+ return;
1939
+ const state = this.#database.prepare('SELECT * FROM todo_reminder_state WHERE agent_id = ?').get(agentId);
1940
+ const cursor = Number(state?.last_context_rowid ?? 0);
1941
+ // Count each newly projected payload once. Compact/Snip/retries must not reset or
1942
+ // increase the cadence; locked original history remains available across restart.
1943
+ const added = this.#all(`SELECT ci.*, ci.rowid AS context_rowid, COALESCE(w.content, ci.content) AS content
1944
+ FROM context_items ci LEFT JOIN working_context_items w ON w.context_item_id = ci.id
1945
+ WHERE ci.agent_id = ? AND ci.rowid > ? ORDER BY ci.rowid`, agentId, cursor);
1946
+ let pending = Number(state?.pending_tokens ?? 0);
1947
+ for (const item of added.map(doc)) {
1948
+ const metadata = item.metadata;
1949
+ if (item.visible !== true || item.locked === true || item.kind === 'compaction' || item.role === 'system' || metadata?.purpose === 'todo_reminder')
1950
+ continue;
1951
+ pending += estimateTokens(json(boundaryMessages([item]).map((message) => ({ role: message.role, content: message.content, ...(message.toolCalls === undefined ? {} : { toolCalls: message.toolCalls }) })))).expected;
1952
+ }
1953
+ const messages = boundaryMessages(activeContextItems(source.items));
1954
+ const boundaries = assertContextBoundaries(messages, { allowOpenTail: true, unsettledItemIds: source.unsettledItemIds });
1955
+ const due = pending >= 30000 && boundaries.legalCuts.at(-1) === messages.length;
1956
+ if (added.length === 0 && !due)
1957
+ return;
1958
+ this.#write(projectId, refs(['agent', agentId]), () => {
1959
+ if (due) {
1960
+ this.#insertStandaloneContextMessage(agentId, 'user', 'Todo reminder: read your current checklist with todo({}). Keep the original user goal and applicable instructions in view. Maintain and update the checklist with todo({items: [...]}) as progress or instructions change; create one if needed. This reminder does not change the user goal or prove completion.', 'message', now(), { purpose: 'todo_reminder' });
1961
+ pending %= 30000;
1962
+ }
1963
+ const latest = this.#one('SELECT COALESCE(MAX(rowid), 0) AS cursor FROM context_items WHERE agent_id = ?', agentId);
1964
+ this.#database.prepare(`INSERT INTO todo_reminder_state(agent_id, last_context_rowid, pending_tokens) VALUES (?, ?, ?)
1965
+ ON CONFLICT(agent_id) DO UPDATE SET last_context_rowid = excluded.last_context_rowid, pending_tokens = excluded.pending_tokens`)
1966
+ .run(agentId, Number(latest.cursor), pending);
1967
+ });
1968
+ }
1558
1969
  getContextItem(projectId, contextItemId) {
1559
1970
  const row = this.#require('context_items', contextItemId);
1560
1971
  this.#ownedAgent(String(row.agent_id), projectId);
@@ -1568,19 +1979,30 @@ export class Store {
1568
1979
  this.#expect(current, expectedRevision);
1569
1980
  if (Number(current.locked) === 1)
1570
1981
  fail('CONTEXT_LOCKED', 409, 'The ContextItem is before the latest Compaction boundary.');
1982
+ if (current.kind === 'tool_calls') {
1983
+ const identity = (text) => json(contextToolCalls(text).map(({ id, name }) => ({ id, name })));
1984
+ if (identity(String(current.content)) !== identity(content))
1985
+ fail('CONTEXT_BOUNDARY_INVALID', 409, 'Editing a tool batch cannot change its call identities or members.');
1986
+ }
1987
+ const before = this.#assertContext(agentId);
1571
1988
  const timestamp = now();
1572
1989
  this.#database.prepare('UPDATE context_items SET content = ?, revision = revision + 1, updated_at = ? WHERE id = ?')
1573
1990
  .run(content, timestamp, contextItemId);
1574
- this.#database.prepare('UPDATE agents SET context_revision = context_revision + 1, revision = revision + 1, updated_at = ? WHERE id = ?')
1575
- .run(timestamp, agentId);
1991
+ assertContextTransition(before, this.#assertContext(agentId), this.#unsettledContextItems(agentId));
1992
+ this.#touchContext(agentId);
1576
1993
  return this.getContextItem(projectId, contextItemId);
1577
1994
  });
1578
1995
  }
1579
1996
  setContextVisibility(projectId, agentId, turnGroupIds, visible, expectedContextRevision) {
1580
1997
  return this.#write(projectId, turnGroupIds.map((entry) => ({ type: 'turn_group', id: entry })), () => {
1581
1998
  const agent = this.getAgent(projectId, agentId);
1999
+ const before = this.#assertContext(agentId);
1582
2000
  if (agent.context_revision !== expectedContextRevision)
1583
2001
  fail('REVISION_CONFLICT', 409, 'The Context changed before this command was applied.');
2002
+ const selected = new Set(turnGroupIds);
2003
+ const selection = this.#all('SELECT * FROM context_items WHERE agent_id = ? ORDER BY ordinal, id', agentId).map(doc)
2004
+ .filter((item) => selected.has(stringField(item.turn_group_id)));
2005
+ assertContextBoundaries(boundaryMessages(selection), { unsettledItemIds: this.#unsettledContextItems(agentId) });
1584
2006
  const statement = this.#database.prepare(`UPDATE context_items SET visible = ?, revision = revision + 1, updated_at = ? WHERE turn_group_id = ? AND agent_id = ? AND locked = 0`);
1585
2007
  for (const groupId of turnGroupIds) {
1586
2008
  const group = this.#require('context_turn_groups', groupId);
@@ -1590,6 +2012,7 @@ export class Store {
1590
2012
  if (statement.run(visible ? 1 : 0, now(), groupId, agentId).changes !== expected)
1591
2013
  fail('CONTEXT_ITEM_CONFLICT', 409, 'A locked TurnGroup cannot change visibility.');
1592
2014
  }
2015
+ assertContextTransition(before, this.#assertContext(agentId), this.#unsettledContextItems(agentId));
1593
2016
  this.#touchContext(agentId);
1594
2017
  return this.#all(`SELECT * FROM context_items WHERE turn_group_id IN (${turnGroupIds.map(() => '?').join(',')}) ORDER BY ordinal`, ...turnGroupIds).map(doc);
1595
2018
  });
@@ -1597,26 +2020,38 @@ export class Store {
1597
2020
  reorderContext(projectId, agentId, turnGroupIds, expectedContextRevision) {
1598
2021
  return this.#write(projectId, turnGroupIds.map((entry) => ({ type: 'turn_group', id: entry })), () => {
1599
2022
  const agent = this.getAgent(projectId, agentId);
2023
+ const before = this.#assertContext(agentId);
1600
2024
  if (agent.context_revision !== expectedContextRevision)
1601
2025
  fail('REVISION_CONFLICT', 409, 'The Context changed before this command was applied.');
1602
2026
  const current = this.#all(`SELECT g.id FROM context_turn_groups g WHERE g.agent_id = ? AND g.state = 'COMPLETE' AND NOT EXISTS (SELECT 1 FROM context_items i WHERE i.turn_group_id = g.id AND i.locked = 1) ORDER BY g.ordinal`, agentId).map((row) => String(row.id));
1603
2027
  if (current.length !== turnGroupIds.length || current.some((entry) => !turnGroupIds.includes(entry)) || new Set(turnGroupIds).size !== turnGroupIds.length)
1604
2028
  fail('CONTEXT_SET_MISMATCH', 409, 'The complete unlocked TurnGroup order is required.');
1605
- const lockedCount = this.#count('SELECT COUNT(*) AS count FROM context_items WHERE agent_id = ? AND locked = 1', agentId);
1606
- const lockedGroups = this.#count(`SELECT COUNT(*) AS count FROM context_turn_groups g WHERE g.agent_id = ? AND EXISTS (SELECT 1 FROM context_items i WHERE i.turn_group_id = g.id AND i.locked = 1)`, agentId);
1607
- const move = this.#database.prepare('UPDATE context_items SET ordinal = ordinal + 1000000000 WHERE turn_group_id = ?');
2029
+ // A Compact boundary may split a TurnGroup. Reuse only movable slots, leaving archived
2030
+ // rows and the retained part of a partially locked group in place.
2031
+ const movable = new Set(current);
2032
+ const slots = this.#all('SELECT ordinal, turn_group_id FROM context_items WHERE agent_id = ? ORDER BY ordinal', agentId)
2033
+ .filter((row) => movable.has(String(row.turn_group_id))).map((row) => Number(row.ordinal));
2034
+ const groupSlots = this.#all('SELECT id, ordinal FROM context_turn_groups WHERE agent_id = ? ORDER BY ordinal', agentId)
2035
+ .filter((row) => movable.has(String(row.id))).map((row) => Number(row.ordinal));
2036
+ const offset = this.#nextOrdinal('context_items', 'agent_id', agentId);
2037
+ const move = this.#database.prepare('UPDATE context_items SET ordinal = ordinal + ? WHERE turn_group_id = ?');
2038
+ for (const groupId of turnGroupIds)
2039
+ move.run(offset, groupId);
2040
+ const moveGroup = this.#database.prepare('UPDATE context_turn_groups SET ordinal = ordinal + ? WHERE id = ?');
2041
+ const groupOffset = this.#nextOrdinal('context_turn_groups', 'agent_id', agentId);
1608
2042
  for (const groupId of turnGroupIds)
1609
- move.run(groupId);
1610
- const updateItem = this.#database.prepare('UPDATE context_items SET ordinal = ?, revision = revision + 1, updated_at = ? WHERE id = ?');
2043
+ moveGroup.run(groupOffset, groupId);
2044
+ const updateItem = this.#database.prepare('UPDATE context_items SET ordinal = ?, updated_at = ? WHERE id = ?');
1611
2045
  const updateGroup = this.#database.prepare('UPDATE context_turn_groups SET ordinal = ?, revision = revision + 1 WHERE id = ?');
1612
- let ordinal = lockedCount;
1613
- for (const [groupOffset, groupId] of turnGroupIds.entries()) {
1614
- updateGroup.run(lockedGroups + groupOffset, groupId);
2046
+ let slot = 0;
2047
+ for (const [index, groupId] of turnGroupIds.entries()) {
2048
+ updateGroup.run(groupSlots[index], groupId);
1615
2049
  for (const row of this.#all('SELECT id FROM context_items WHERE turn_group_id = ? ORDER BY turn_group_ordinal', groupId)) {
1616
- updateItem.run(ordinal, now(), row.id);
1617
- ordinal += 1;
2050
+ updateItem.run(slots[slot], now(), row.id);
2051
+ slot += 1;
1618
2052
  }
1619
2053
  }
2054
+ assertContextTransition(before, this.#assertContext(agentId), this.#unsettledContextItems(agentId));
1620
2055
  this.#touchContext(agentId);
1621
2056
  return this.#all('SELECT * FROM context_items WHERE agent_id = ? ORDER BY ordinal', agentId).map(doc);
1622
2057
  });
@@ -1676,11 +2111,14 @@ export class Store {
1676
2111
  const agent = this.getAgent(projectId, agentId);
1677
2112
  if (agent.context_revision !== expectedContextRevision)
1678
2113
  fail('REVISION_CONFLICT', 409, 'The Context changed before this command was applied.');
1679
- if (stages === undefined)
1680
- this.#database.prepare('DELETE FROM projection_decisions WHERE agent_id = ?').run(agentId);
1681
- else
1682
- this.#database.prepare(`DELETE FROM projection_decisions WHERE agent_id = ? AND stage IN (${stages.map(() => '?').join(',')})`).run(agentId, ...stages);
1683
- return { agent_id: agentId, context_revision: agent.context_revision };
2114
+ if (stages?.some((stage) => stage !== 'snip'))
2115
+ fail('VALIDATION_ERROR', 400, 'Only the snip projection can be reset.');
2116
+ if (stages === undefined || stages.includes('snip')) {
2117
+ this.#database.prepare("DELETE FROM projection_decisions WHERE agent_id = ? AND stage = 'snip'").run(agentId);
2118
+ this.#database.prepare(`UPDATE working_context_items SET content = (SELECT content FROM context_items WHERE id = context_item_id), snipped = 0 WHERE agent_id = ? AND snipped = 1`).run(agentId);
2119
+ this.#touchContext(agentId);
2120
+ }
2121
+ return { agent_id: agentId, context_revision: Number(this.getAgent(projectId, agentId).context_revision) };
1684
2122
  });
1685
2123
  }
1686
2124
  appendProjectionDecision(projectId, agentId, attemptId, stage, sourceTokens, projectedTokens, details) {
@@ -1692,18 +2130,41 @@ export class Store {
1692
2130
  return doc(this.#require('projection_decisions', decisionId));
1693
2131
  });
1694
2132
  }
1695
- compactContext(projectId, agentId, summary, preserveFromOrdinal) {
2133
+ compactContext(projectId, agentId, summary, coveredItemIds, expectedContextRevision, focus, expectedAgentRevision) {
1696
2134
  const contextId = id('context');
1697
2135
  const turnGroupId = id('turn');
1698
2136
  const timestamp = now();
1699
2137
  return this.#write(projectId, refs(['turn_group', turnGroupId], ['context_item', contextId], ['agent', agentId]), () => {
1700
- this.getAgent(projectId, agentId);
1701
- this.#database.prepare('UPDATE context_items SET locked = 1, revision = revision + 1, updated_at = ? WHERE agent_id = ? AND ordinal < ?')
1702
- .run(timestamp, agentId, preserveFromOrdinal);
1703
- const ordinal = this.#nextOrdinal('context_items', 'agent_id', agentId);
2138
+ const agent = this.getAgent(projectId, agentId);
2139
+ if (agent.context_revision !== expectedContextRevision)
2140
+ fail('REVISION_CONFLICT', 409, 'Context changed while Compaction was running.');
2141
+ if (expectedAgentRevision !== undefined && agent.revision !== expectedAgentRevision)
2142
+ fail('REVISION_CONFLICT', 409, 'Agent state changed while Compaction was running.');
2143
+ if (summary.trim().length === 0 || coveredItemIds.length === 0)
2144
+ fail('COMPACTION_INVALID', 422, 'Compaction requires a nonempty summary and source range.');
2145
+ const active = activeContextItems(this.#all('SELECT * FROM context_items WHERE agent_id = ? ORDER BY ordinal', agentId).map(doc))
2146
+ .filter((item) => !(item.kind === 'message' && item.role === 'system'));
2147
+ const covered = active.slice(0, coveredItemIds.length);
2148
+ if (covered.length !== coveredItemIds.length || covered.some((item, index) => item.id !== coveredItemIds[index]))
2149
+ fail('CONTEXT_SET_MISMATCH', 409, 'Compaction must cover an active history prefix.');
2150
+ const boundaries = assertContextBoundaries(boundaryMessages(active), { allowOpenTail: true, unsettledItemIds: this.#unsettledContextItems(agentId) });
2151
+ if (!boundaries.legalCuts.includes(coveredItemIds.length))
2152
+ fail('CONTEXT_BOUNDARY_INVALID', 409, 'Compaction cannot split or cover an unfinished protocol group.', { legal_cuts: boundaries.legalCuts });
2153
+ const firstRetained = active[coveredItemIds.length];
2154
+ const ordinal = firstRetained === undefined ? Math.max(...covered.map((item) => Number(item.ordinal))) + 1 : Number(firstRetained.ordinal);
2155
+ const lock = this.#database.prepare('UPDATE context_items SET locked = 1, revision = revision + 1, updated_at = ? WHERE id = ?');
2156
+ for (const itemId of coveredItemIds)
2157
+ lock.run(timestamp, itemId);
2158
+ // Insert at the cutoff, before the retained tail. Descending moves respect the unique ordinal.
2159
+ const shift = this.#database.prepare('UPDATE context_items SET ordinal = ordinal + 1 WHERE id = ?');
2160
+ for (const row of this.#all('SELECT id FROM context_items WHERE agent_id = ? AND ordinal >= ? ORDER BY ordinal DESC', agentId, ordinal))
2161
+ shift.run(row.id);
1704
2162
  this.#insertTurnGroup(turnGroupId, agentId, 'compaction', 'COMPLETE', timestamp);
1705
- this.#database.prepare(`INSERT INTO context_items(id, agent_id, turn_group_id, turn_group_ordinal, kind, source_id, role, content, ordinal, visible, locked, revision, created_at, updated_at) VALUES (?, ?, ?, 0, 'compaction', ?, 'system', ?, ?, 1, 0, 0, ?, ?)`)
2163
+ this.#database.prepare(`INSERT INTO context_items(id, agent_id, turn_group_id, turn_group_ordinal, kind, source_id, role, content, ordinal, visible, locked, revision, created_at, updated_at) VALUES (?, ?, ?, 0, 'compaction', ?, 'assistant', ?, ?, 1, 0, 0, ?, ?)`)
1706
2164
  .run(contextId, agentId, turnGroupId, contextId, summary, ordinal, timestamp, timestamp);
2165
+ this.#database.prepare('INSERT INTO context_compactions(context_item_id, source_revision, covered_item_ids_json, focus) VALUES (?, ?, ?, ?)')
2166
+ .run(contextId, expectedContextRevision, json(coveredItemIds), focus ?? null);
2167
+ this.#database.prepare('DELETE FROM working_context_items WHERE agent_id = ?').run(agentId);
1707
2168
  this.#touchContext(agentId);
1708
2169
  return this.getContextItem(projectId, contextId);
1709
2170
  });
@@ -1724,7 +2185,7 @@ export class Store {
1724
2185
  const boundary = this.#require('context_turn_groups', input.throughTurnGroupId);
1725
2186
  if (boundary.agent_id !== sourceAgentId || boundary.state !== 'COMPLETE')
1726
2187
  fail('CONTEXT_GROUP_INVALID', 409, 'Context Fork requires a complete source TurnGroup.');
1727
- if (this.#count('SELECT COUNT(*) AS count FROM context_items WHERE turn_group_id = ? AND locked = 1', input.throughTurnGroupId) !== 0)
2188
+ if (this.#count('SELECT COUNT(*) AS count FROM context_items WHERE turn_group_id = ? AND locked = 0', input.throughTurnGroupId) === 0)
1728
2189
  fail('CONTEXT_LOCKED', 409, 'The Context Fork boundary is locked.');
1729
2190
  const throughOrdinal = Number(this.#one('SELECT MAX(ordinal) AS ordinal FROM context_items WHERE turn_group_id = ?', input.throughTurnGroupId).ordinal);
1730
2191
  const taskId = stringField(source.task_id);
@@ -1754,6 +2215,26 @@ export class Store {
1754
2215
  return this.getAgent(projectId, targetAgentId);
1755
2216
  });
1756
2217
  }
2218
+ /** Persist one completed Provider response atomically with its tool batch. */
2219
+ appendModelResponse(projectId, agentId, stepId, text, calls, response) {
2220
+ const messageId = id('message');
2221
+ const contextId = id('context');
2222
+ const timestamp = now();
2223
+ const metadata = json({ source_step_id: stepId, model_response: response });
2224
+ return this.#write(projectId, refs(['message', messageId], ['context_item', contextId]), () => {
2225
+ this.getAgent(projectId, agentId);
2226
+ const turnGroupId = stringField(this.getStep(projectId, stepId).turn_group_id);
2227
+ // Display text remains separate from protocol blocks; one Context item keeps the
2228
+ // reasoning, text and tool calls indivisible at Compact/Fork boundaries.
2229
+ this.#database.prepare(`INSERT INTO messages(id, agent_id, source_agent_id, role, content, metadata_json, created_at) VALUES (?, ?, NULL, 'assistant', ?, ?, ?)`)
2230
+ .run(messageId, agentId, text, metadata, timestamp);
2231
+ const hasCalls = calls.length > 0;
2232
+ this.#database.prepare(`INSERT INTO context_items(id, agent_id, turn_group_id, turn_group_ordinal, kind, source_id, role, content, ordinal, visible, locked, revision, created_at, updated_at, metadata_json) VALUES (?, ?, ?, ?, ?, ?, 'assistant', ?, ?, 1, 0, 0, ?, ?, ?)`)
2233
+ .run(contextId, agentId, turnGroupId, this.#nextOrdinal('context_items', 'turn_group_id', turnGroupId), hasCalls ? 'tool_calls' : 'message', hasCalls ? contextId : messageId, hasCalls ? json(calls) : text, this.#nextOrdinal('context_items', 'agent_id', agentId), timestamp, timestamp, metadata);
2234
+ this.#touchContext(agentId);
2235
+ return this.getContextItem(projectId, contextId);
2236
+ });
2237
+ }
1757
2238
  appendAssistantMessage(projectId, agentId, stepId, content) {
1758
2239
  const messageId = id('message');
1759
2240
  const contextId = id('context');
@@ -1781,6 +2262,7 @@ export class Store {
1781
2262
  const groupOrdinal = this.#nextOrdinal('context_items', 'turn_group_id', turnGroupId);
1782
2263
  this.#database.prepare(`INSERT INTO context_items(id, agent_id, turn_group_id, turn_group_ordinal, kind, source_id, role, content, ordinal, visible, locked, revision, created_at, updated_at) VALUES (?, ?, ?, ?, 'tool_calls', ?, 'assistant', ?, ?, 1, 0, 0, ?, ?)`)
1783
2264
  .run(contextId, agentId, turnGroupId, groupOrdinal, contextId, json(calls), ordinal, timestamp, timestamp);
2265
+ this.#database.prepare('UPDATE context_items SET metadata_json = ? WHERE id = ?').run(json({ source_step_id: stepId }), contextId);
1784
2266
  this.#touchContext(agentId);
1785
2267
  return this.getContextItem(projectId, contextId);
1786
2268
  });
@@ -1794,17 +2276,31 @@ export class Store {
1794
2276
  const runId = String(step.run_id);
1795
2277
  const run = this.#require('runs', runId);
1796
2278
  const agentId = String(run.agent_id);
1797
- return this.#write(projectId, refs(['step', stepId], ['run', runId], ['agent', agentId]), () => {
2279
+ const changes = [...refs(['step', stepId], ['run', runId], ['agent', agentId])];
2280
+ return this.#write(projectId, changes, () => {
1798
2281
  const timestamp = now();
1799
2282
  this.#database.prepare(`UPDATE steps SET state = 'TERMINAL', outcome = ?, error_code = ?, error_message = ?, updated_at = ? WHERE id = ? AND state != 'TERMINAL'`)
1800
2283
  .run(outcome, errorCode, errorMessage, timestamp, stepId);
1801
2284
  const hasPending = this.#count('SELECT COUNT(*) AS count FROM pending_messages WHERE agent_id = ?', agentId) > 0;
2285
+ for (const call of this.#all("SELECT c.* FROM calls c JOIN steps s ON s.id = c.step_id WHERE s.run_id = ? AND c.state != 'TERMINAL'", runId)) {
2286
+ changes.push(...this.#interruptCall(call, errorCode ?? 'RUN_ENDED', errorMessage ?? 'The Run ended before this Call completed.', timestamp));
2287
+ }
2288
+ changes.push(...this.#recoverResultContext(timestamp, agentId));
1802
2289
  const continueRun = outcome === 'success' && hasPending;
1803
2290
  this.#database.prepare(`UPDATE runs SET state = ?, outcome = ?, error_code = ?, error_message = ?, revision = revision + 1, updated_at = ? WHERE id = ?`)
1804
2291
  .run(continueRun ? 'READY' : 'TERMINAL', continueRun ? null : outcome, continueRun ? null : errorCode, continueRun ? null : errorMessage, timestamp, runId);
1805
- if (!continueRun && hasPending && errorCode !== 'COMPACTION_FAILED') {
1806
- this.#database.prepare(`INSERT INTO runs(id, agent_id, goal_message_ids_json, state, outcome, error_code, revision, created_at, updated_at) VALUES (?, ?, '[]', 'READY', NULL, NULL, 0, ?, ?)`)
1807
- .run(id('run'), agentId, timestamp, timestamp);
2292
+ if (outcome === 'failed' && run.state !== 'TERMINAL' && hasPending) {
2293
+ const consumed = new Set(strings(parseJson(String(run.goal_message_ids_json))));
2294
+ const freshInput = this.#all('SELECT message_id FROM pending_messages WHERE agent_id = ?', agentId)
2295
+ .some(message => !consumed.has(String(message.message_id)));
2296
+ // Restored input from a failed compaction is not a new execution request.
2297
+ // Only input not consumed by this Run can schedule a fresh Run; old Calls stay terminal.
2298
+ if (freshInput) {
2299
+ const nextRunId = id('run');
2300
+ this.#database.prepare(`INSERT INTO runs(id, agent_id, goal_message_ids_json, state, outcome, error_code, revision, created_at, updated_at) VALUES (?, ?, '[]', 'READY', NULL, NULL, 0, ?, ?)`)
2301
+ .run(nextRunId, agentId, timestamp, timestamp);
2302
+ changes.push({ type: 'run', id: nextRunId });
2303
+ }
1808
2304
  }
1809
2305
  this.#database.prepare(`UPDATE agents SET state = 'IDLE', revision = revision + 1, updated_at = ? WHERE id = ?`)
1810
2306
  .run(timestamp, agentId);
@@ -1834,15 +2330,18 @@ export class Store {
1834
2330
  }
1835
2331
  yieldResults(projectId, agentId, callIds) {
1836
2332
  const timestamp = now();
1837
- return this.#write(projectId, callIds.map((entry) => ({ type: 'result', id: entry })), () => {
2333
+ const changes = [];
2334
+ return this.#write(projectId, changes, () => {
1838
2335
  this.getAgent(projectId, agentId);
1839
2336
  const rows = callIds.map((callId) => {
1840
2337
  const call = this.#require('calls', callId);
2338
+ owned(call, 'agent_id', agentId);
1841
2339
  if (call.state !== 'TERMINAL')
1842
2340
  fail('CALL_STATE_CONFLICT', 409, 'All Calls must be terminal before Result Yield.');
1843
2341
  const result = this.#database.prepare('SELECT * FROM results WHERE call_id = ?').get(callId);
1844
2342
  if (result === undefined)
1845
2343
  fail('RESULT_NOT_FOUND', 503, 'A terminal Call has no Result.');
2344
+ changes.push({ type: 'result', id: String(result.id) });
1846
2345
  return { call, result };
1847
2346
  }).sort((left, right) => Number(left.call.call_ordinal ?? 0) - Number(right.call.call_ordinal ?? 0));
1848
2347
  const yielded = [];
@@ -1851,26 +2350,153 @@ export class Store {
1851
2350
  fail('DATABASE_CORRUPT', 503, 'An LLM Call has no Provider Call ID.');
1852
2351
  if (typeof call.step_id !== 'string')
1853
2352
  fail('DATABASE_CORRUPT', 503, 'An LLM Call has no Step.');
2353
+ if (this.#count("SELECT COUNT(*) AS count FROM context_items WHERE agent_id = ? AND kind = 'result' AND source_id = ? AND tool_call_id IS NOT NULL", agentId, result.id) > 0)
2354
+ continue;
1854
2355
  const turnGroupId = String(this.#require('steps', call.step_id).turn_group_id);
1855
- const contextId = id('context');
1856
- const ordinal = this.#nextOrdinal('context_items', 'agent_id', agentId);
1857
- const groupOrdinal = this.#nextOrdinal('context_items', 'turn_group_id', turnGroupId);
1858
- const artifacts = this.#all('SELECT artifact_id FROM result_artifacts WHERE result_id = ? ORDER BY artifact_id', result.id).map((row) => String(row.artifact_id));
1859
- const content = json({ content: parseJson(String(result.content_json)), artifacts, truncated: Number(result.truncated) === 1 });
1860
- this.#database.prepare(`INSERT INTO context_items(id, agent_id, turn_group_id, turn_group_ordinal, kind, source_id, role, content, tool_call_id, tool_name, ordinal, visible, locked, revision, created_at, updated_at) VALUES (?, ?, ?, ?, 'result', ?, 'tool', ?, ?, ?, ?, 1, 0, 0, ?, ?)`)
1861
- .run(contextId, agentId, turnGroupId, groupOrdinal, String(result.id), content, call.provider_call_id, String(call.capability_id), ordinal, timestamp, timestamp);
2356
+ this.#insertResultContext(agentId, turnGroupId, call, result, timestamp, this.#nextOrdinal('context_items', 'agent_id', agentId), this.#nextOrdinal('context_items', 'turn_group_id', turnGroupId));
1862
2357
  yielded.push({ ...doc(result), tool_call_id: call.provider_call_id, tool_name: String(call.capability_id) });
1863
2358
  }
1864
2359
  this.#touchContext(agentId);
1865
2360
  return yielded;
1866
2361
  });
1867
2362
  }
2363
+ #insertResultContext(agentId, turnGroupId, call, result, timestamp, ordinal, groupOrdinal) {
2364
+ const contextId = id('context');
2365
+ const content = this.#resultContextContent(call, doc(result));
2366
+ this.#database.prepare(`INSERT INTO context_items(id, agent_id, turn_group_id, turn_group_ordinal, kind, source_id, role, content, tool_call_id, tool_name, ordinal, visible, locked, revision, created_at, updated_at) VALUES (?, ?, ?, ?, 'result', ?, 'tool', ?, ?, ?, ?, 1, 0, 0, ?, ?)`)
2367
+ .run(contextId, agentId, turnGroupId, groupOrdinal, String(result.id), content, call.provider_call_id, String(call.capability_id), ordinal, timestamp, timestamp);
2368
+ return contextId;
2369
+ }
2370
+ // Old partial batches may have persisted tool IDs but no Call. Create only an
2371
+ // interrupted fact anchored to the recorded Step; never schedule these tools.
2372
+ #materializeInterruptedCalls(timestamp) {
2373
+ const changes = [];
2374
+ for (const batch of this.#all("SELECT * FROM context_items WHERE kind = 'tool_calls' AND visible = 1 AND locked = 0")) {
2375
+ const metadata = jsonRecord(parseJson(typeof batch.metadata_json === 'string' ? batch.metadata_json : null));
2376
+ if (typeof metadata?.source_step_id !== 'string')
2377
+ continue;
2378
+ const step = this.#database.prepare('SELECT s.*, a.workspace_id, t.project_id FROM steps s JOIN runs r ON r.id = s.run_id JOIN agents a ON a.id = r.agent_id JOIN tasks t ON t.id = a.task_id WHERE s.id = ? AND r.agent_id = ? AND s.turn_group_id = ?').get(metadata.source_step_id, batch.agent_id, batch.turn_group_id);
2379
+ if (step === undefined)
2380
+ continue;
2381
+ for (const [ordinal, tool] of contextToolCalls(String(batch.content)).entries()) {
2382
+ if (this.#count('SELECT COUNT(*) AS count FROM calls WHERE step_id = ? AND provider_call_id = ? AND parent_call_id IS NULL', step.id, tool.id) > 0)
2383
+ continue;
2384
+ const callId = id('call');
2385
+ this.#database.prepare(`INSERT INTO calls(id, project_id, workspace_id, agent_id, step_id, source, capability_id, provider_call_id, call_ordinal, input_json, effect, state, outcome_unknown, revision, created_at, updated_at)
2386
+ VALUES (?, ?, ?, ?, ?, 'llm', ?, ?, ?, '{}', 'full', 'QUEUED', 0, 0, ?, ?)`)
2387
+ .run(callId, step.project_id, step.workspace_id, batch.agent_id, step.id, tool.name, tool.id, ordinal, timestamp, timestamp);
2388
+ changes.push(...this.#interruptCall(this.#require('calls', callId), 'PROCESS_INTERRUPTED', 'Execution was interrupted before a Call was recorded; no automatic retry will occur.', timestamp));
2389
+ }
2390
+ }
2391
+ return changes;
2392
+ }
2393
+ #recoverResultContext(timestamp, onlyAgentId) {
2394
+ const changes = [];
2395
+ const touched = new Set();
2396
+ const batches = this.#all(`SELECT i.id FROM context_items i WHERE i.kind = 'tool_calls' AND i.visible = 1 AND i.locked = 0
2397
+ AND EXISTS (SELECT 1 FROM calls c JOIN steps s ON s.id = c.step_id JOIN results r ON r.call_id = c.id
2398
+ WHERE c.agent_id = i.agent_id AND s.turn_group_id = i.turn_group_id AND c.source = 'llm' AND c.state = 'TERMINAL'
2399
+ AND NOT EXISTS (SELECT 1 FROM context_items response WHERE response.agent_id = i.agent_id AND response.kind = 'result'
2400
+ AND response.source_id = r.id AND response.tool_call_id IS NOT NULL)) ORDER BY i.agent_id, i.ordinal`);
2401
+ for (const { id: batchId } of batches) {
2402
+ // Earlier insertions can move this batch; always use current ordinals.
2403
+ const batch = this.#require('context_items', String(batchId));
2404
+ const agentId = String(batch.agent_id);
2405
+ if (onlyAgentId !== undefined && agentId !== onlyAgentId)
2406
+ continue;
2407
+ const groupId = String(batch.turn_group_id);
2408
+ const tools = contextToolCalls(String(batch.content));
2409
+ const toolIds = new Set(tools.map((tool) => tool.id));
2410
+ const following = this.#all('SELECT * FROM context_items WHERE agent_id = ? AND ordinal > ? ORDER BY ordinal', agentId, batch.ordinal);
2411
+ const existing = [];
2412
+ for (const item of following) {
2413
+ if (item.kind !== 'result' || item.turn_group_id !== groupId || !toolIds.has(String(item.tool_call_id)))
2414
+ break;
2415
+ existing.push(item);
2416
+ }
2417
+ const present = new Set(existing.map((item) => String(item.tool_call_id)));
2418
+ if (tools.every((tool) => present.has(tool.id)))
2419
+ continue;
2420
+ const metadata = jsonRecord(parseJson(typeof batch.metadata_json === 'string' ? batch.metadata_json : null));
2421
+ let stepId = typeof metadata?.source_step_id === 'string' ? metadata.source_step_id : undefined;
2422
+ if (stepId === undefined && existing.length > 0) {
2423
+ const steps = new Set(existing.flatMap((item) => this.#all('SELECT c.step_id FROM results r JOIN calls c ON c.id = r.call_id WHERE r.id = ? AND c.agent_id = ?', item.source_id, agentId).map((row) => String(row.step_id))));
2424
+ if (steps.size === 1)
2425
+ stepId = [...steps][0];
2426
+ }
2427
+ const last = existing.at(-1) ?? batch;
2428
+ let ordinal = Number(last.ordinal) + 1;
2429
+ let groupOrdinal = Number(last.turn_group_ordinal) + 1;
2430
+ for (const tool of tools) {
2431
+ if (present.has(tool.id))
2432
+ continue;
2433
+ // Legacy batches without an explicit Step or an existing Result anchor must
2434
+ // have an unambiguous batch identity within this TurnGroup.
2435
+ if (stepId === undefined) {
2436
+ const owners = this.#all("SELECT content FROM context_items WHERE agent_id = ? AND turn_group_id = ? AND kind = 'tool_calls'", agentId, groupId)
2437
+ .filter((row) => contextToolCalls(String(row.content)).some((entry) => entry.id === tool.id));
2438
+ if (owners.length !== 1)
2439
+ continue;
2440
+ }
2441
+ const candidates = this.#all(`SELECT c.* FROM calls c JOIN steps s ON s.id = c.step_id
2442
+ WHERE c.agent_id = ? AND s.turn_group_id = ? AND c.source = 'llm' AND c.parent_call_id IS NULL
2443
+ AND c.provider_call_id = ? AND c.capability_id = ? ${stepId === undefined ? '' : 'AND c.step_id = ?'}`, agentId, groupId, tool.id, tool.name, ...(stepId === undefined ? [] : [stepId]));
2444
+ if (candidates.length !== 1)
2445
+ continue;
2446
+ const call = candidates[0];
2447
+ if (call.state !== 'TERMINAL')
2448
+ continue;
2449
+ const result = this.#database.prepare('SELECT * FROM results WHERE call_id = ?').get(call.id);
2450
+ if (result === undefined)
2451
+ continue;
2452
+ // A copied/hidden/already projected response must never be resurrected or duplicated.
2453
+ if (this.#count("SELECT COUNT(*) AS count FROM context_items WHERE agent_id = ? AND kind = 'result' AND source_id = ? AND tool_call_id IS NOT NULL", agentId, result.id) > 0)
2454
+ continue;
2455
+ const shift = this.#database.prepare('UPDATE context_items SET ordinal = ordinal + 1 WHERE id = ?');
2456
+ for (const item of this.#all('SELECT id FROM context_items WHERE agent_id = ? AND ordinal >= ? ORDER BY ordinal DESC', agentId, ordinal))
2457
+ shift.run(item.id);
2458
+ const shiftGroup = this.#database.prepare('UPDATE context_items SET turn_group_ordinal = turn_group_ordinal + 1 WHERE id = ?');
2459
+ for (const item of this.#all('SELECT id FROM context_items WHERE turn_group_id = ? AND turn_group_ordinal >= ? ORDER BY turn_group_ordinal DESC', groupId, groupOrdinal))
2460
+ shiftGroup.run(item.id);
2461
+ const contextId = this.#insertResultContext(agentId, groupId, call, result, timestamp, ordinal++, groupOrdinal++);
2462
+ changes.push({ type: 'context_item', id: contextId });
2463
+ touched.add(agentId);
2464
+ }
2465
+ }
2466
+ for (const agentId of touched) {
2467
+ this.#touchContext(agentId);
2468
+ changes.push({ type: 'agent', id: agentId });
2469
+ }
2470
+ return changes;
2471
+ }
2472
+ #resultContextContent(call, result) {
2473
+ const artifacts = this.#all('SELECT artifact_id FROM result_artifacts WHERE result_id = ? ORDER BY artifact_id', result.id).map((row) => String(row.artifact_id));
2474
+ const view = { content: result.content ?? null, artifacts, truncated: result.truncated === true };
2475
+ // PTC roots retain their existing protocol result; nested program results are not auto-yielded.
2476
+ if (call.source === 'program' || call.capability_id === 'programmatic_tool_run')
2477
+ return json(view);
2478
+ const envelope = {
2479
+ result_id: stringField(result.id),
2480
+ call_id: String(call.id),
2481
+ outcome: stringField(result.outcome),
2482
+ outcome_unknown: Number(call.outcome_unknown) === 1,
2483
+ error_code: result.error_code === null ? null : stringField(result.error_code),
2484
+ error_message: result.error_message === null ? null : stringField(result.error_message),
2485
+ ...view,
2486
+ payload: { capture: result.capture === 'complete' || result.capture === 'partial' ? result.capture : 'unknown', stored: result.stored === 'full' ? 'full' : 'unknown', view: result.truncated === true ? 'preview' : 'full' }
2487
+ };
2488
+ return json(envelope);
2489
+ }
1868
2490
  cancelAgent(projectId, agentId) {
1869
- return this.#write(projectId, refs(['agent', agentId]), () => {
2491
+ const changes = [...refs(['agent', agentId]), ...this.#all("SELECT id FROM runs WHERE agent_id = ? AND state != 'TERMINAL'", agentId).map(row => ({ type: 'run', id: String(row.id) }))];
2492
+ return this.#write(projectId, changes, () => {
1870
2493
  this.getAgent(projectId, agentId);
1871
2494
  const timestamp = now();
1872
2495
  this.#database.prepare(`UPDATE model_attempts SET state = 'TERMINAL', outcome = 'cancelled', error_code = 'USER_CANCELLED', updated_at = ? WHERE agent_id = ? AND state != 'TERMINAL'`).run(timestamp, agentId);
1873
- this.#database.prepare(`UPDATE calls SET state = 'TERMINAL', outcome = 'cancelled', outcome_unknown = CASE WHEN state = 'RUNNING' THEN 1 ELSE 0 END, revision = revision + 1, updated_at = ? WHERE agent_id = ? AND state != 'TERMINAL'`).run(timestamp, agentId);
2496
+ for (const call of this.#all("SELECT * FROM calls WHERE agent_id = ? AND state != 'TERMINAL'", agentId)) {
2497
+ changes.push(...this.#interruptCall(call, 'CALL_CANCELLED', 'The Agent was stopped; this Call will not be resumed.', timestamp));
2498
+ }
2499
+ changes.push(...this.#recoverResultContext(timestamp, agentId));
1874
2500
  this.#database.prepare(`UPDATE steps SET state = 'TERMINAL', outcome = 'cancelled', error_code = 'USER_CANCELLED', updated_at = ? WHERE run_id IN (SELECT id FROM runs WHERE agent_id = ?) AND state != 'TERMINAL'`).run(timestamp, agentId);
1875
2501
  this.#database.prepare(`UPDATE runs SET state = 'TERMINAL', outcome = 'cancelled', error_code = 'USER_CANCELLED', revision = revision + 1, updated_at = ? WHERE agent_id = ? AND state != 'TERMINAL'`).run(timestamp, agentId);
1876
2502
  this.#database.prepare(`UPDATE agents SET state = 'IDLE', revision = revision + 1, updated_at = ? WHERE id = ?`).run(timestamp, agentId);
@@ -1907,17 +2533,29 @@ export class Store {
1907
2533
  'instance', 'projects', 'workspaces', 'workspace_paths', 'tasks', 'sessions', 'agents', 'messages',
1908
2534
  'pending_messages', 'runs', 'steps', 'model_attempts', 'attempt_capabilities', 'attempt_tool_ordinals',
1909
2535
  'call_partitions', 'calls', 'results', 'artifacts', 'result_artifacts', 'context_turn_groups',
1910
- 'context_items', 'context_overrides', 'projection_decisions', 'capability_assignments', 'capability_loads',
1911
- 'agent_plans', 'ingress_requests', 'side_effect_ledgers'
2536
+ 'context_items', 'context_overrides', 'working_contexts', 'working_context_items', 'context_compactions', 'projection_decisions', 'capability_assignments', 'capability_loads',
2537
+ 'agent_plans', 'todo_reminder_state', 'ingress_requests', 'side_effect_ledgers', 'purge_cleanups'
1912
2538
  ];
1913
- return Object.fromEntries(tables.map((table) => [table, this.#all(`SELECT * FROM ${table}`).map(rawJson)]));
2539
+ return Object.fromEntries(tables.map((table) => [table, this.#all(`SELECT ${table === 'context_items' ? 'rowid, *' : '*'} FROM ${table} ORDER BY rowid`).map(rawJson)]));
1914
2540
  }
1915
- purgeArchived(projectId, before) {
2541
+ purgeArchived(projectId, before, executorStateId) {
1916
2542
  return this.#write(projectId, refs(['maintenance', projectId]), () => {
1917
2543
  const tasks = this.#all('SELECT id FROM tasks WHERE project_id = ? AND archived_at IS NOT NULL AND archived_at < ?', projectId, before).map((row) => String(row.id));
1918
- return { purged_tasks: tasks.length, ...this.#purgeTaskRows(tasks) };
2544
+ return { purged_tasks: tasks.length, ...this.#purgeTaskRows(projectId, tasks, executorStateId) };
1919
2545
  });
1920
2546
  }
2547
+ pendingPurges(projectId) {
2548
+ this.getProject(projectId);
2549
+ return this.#all('SELECT * FROM purge_cleanups WHERE project_id = ? ORDER BY created_at, id', projectId).map(doc);
2550
+ }
2551
+ completePurge(projectId, cleanupId) {
2552
+ this.#write(projectId, refs(['maintenance', projectId]), () => {
2553
+ this.#database.prepare('DELETE FROM purge_cleanups WHERE project_id = ? AND id = ?').run(projectId, cleanupId);
2554
+ });
2555
+ }
2556
+ artifactPathReferenced(relativePath) {
2557
+ return this.#count('SELECT COUNT(*) AS count FROM artifacts WHERE relative_path = ?', relativePath) > 0;
2558
+ }
1921
2559
  snapshot(projectId, maxEntities) {
1922
2560
  const project = this.getProject(projectId);
1923
2561
  const workspaces = this.listWorkspaces(projectId);
@@ -1948,7 +2586,47 @@ export class Store {
1948
2586
  this.#database.prepare('INSERT INTO ingress_requests(client_id, key, operation, request_digest, response_json, created_at) VALUES (?, ?, ?, ?, ?, ?)')
1949
2587
  .run(input.clientId, input.key, input.operation, input.digest, json(response), now());
1950
2588
  }
2589
+ #observationStates(changes) {
2590
+ const values = new Map();
2591
+ for (const entry of changes) {
2592
+ if (!['call', 'run', 'agent'].includes(entry.type))
2593
+ continue;
2594
+ const row = this.#database.prepare(`SELECT state, ${entry.type === 'agent' ? 'NULL' : 'outcome'} AS outcome FROM ${entry.type}s WHERE id = ?`).get(entry.id);
2595
+ if (row !== undefined)
2596
+ values.set(`${entry.type}:${entry.id}`, `${String(row.state)}:${String(row.outcome)}`);
2597
+ }
2598
+ return values;
2599
+ }
2600
+ #observe(changes, previous = new Map()) {
2601
+ if (this.observability === undefined)
2602
+ return;
2603
+ const seen = new Set();
2604
+ for (const entry of changes) {
2605
+ const key = `${entry.type}:${entry.id}`;
2606
+ if (seen.has(key) || !['call', 'run', 'agent'].includes(entry.type))
2607
+ continue;
2608
+ seen.add(key);
2609
+ const row = this.#database.prepare(`SELECT * FROM ${entry.type}s WHERE id = ?`).get(entry.id);
2610
+ if (row === undefined || previous.get(key) === `${String(row.state)}:${typeof row.outcome === 'string' ? row.outcome : 'null'}`)
2611
+ continue;
2612
+ const result = entry.type === 'call' ? this.#database.prepare('SELECT error_code FROM results WHERE call_id = ?').get(entry.id) : undefined;
2613
+ this.observability.record(entry.type === 'call' ? 'call' : 'agent', entry.type, String(row.outcome ?? row.state).toLowerCase(), 0, {
2614
+ [`${entry.type}_id`]: entry.id,
2615
+ project_id: typeof row.project_id === 'string' ? row.project_id : null,
2616
+ agent_id: entry.type === 'agent' ? entry.id : typeof row.agent_id === 'string' ? row.agent_id : null,
2617
+ state: String(row.state), outcome: typeof row.outcome === 'string' ? row.outcome : null,
2618
+ error_code: typeof result?.error_code === 'string' ? result.error_code : typeof row.error_code === 'string' ? row.error_code : null,
2619
+ outcome_unknown: row.outcome_unknown === 1
2620
+ });
2621
+ }
2622
+ }
1951
2623
  #write(projectId, changes, command) {
2624
+ if (this.#batchChanges !== undefined) {
2625
+ this.#batchChanges.push(...changes);
2626
+ return command();
2627
+ }
2628
+ const previous = this.observability === undefined ? new Map() : this.#observationStates(changes);
2629
+ const before = new Map(changes.map((entry) => [`${entry.type}:${entry.id}`, this.#describeChange(entry)]));
1952
2630
  const transaction = this.#database.transaction(() => {
1953
2631
  const value = command();
1954
2632
  this.#database.prepare('UPDATE instance SET state_revision = state_revision + 1 WHERE id = 1').run();
@@ -1961,9 +2639,14 @@ export class Store {
1961
2639
  const change = {
1962
2640
  projectId,
1963
2641
  stateRevision: result.stateRevision,
1964
- changes: changes.map((entry) => this.#describeChange(entry))
2642
+ changes: changes.map((entry) => {
2643
+ const current = this.#describeChange(entry);
2644
+ const prior = before.get(`${entry.type}:${entry.id}`);
2645
+ return current.change === 'deleted' && prior !== undefined ? { ...prior, change: 'deleted', revision: null } : current;
2646
+ })
1965
2647
  };
1966
2648
  this.#events.publish(change);
2649
+ this.#observe(changes, previous);
1967
2650
  return result.value;
1968
2651
  }
1969
2652
  /**
@@ -1978,7 +2661,7 @@ export class Store {
1978
2661
  const owner = OWNERSHIP[entry.type];
1979
2662
  const row = TABLE_BY_ENTITY[entry.type] === undefined
1980
2663
  ? undefined
1981
- : this.#all(`SELECT * FROM ${TABLE_BY_ENTITY[entry.type] ?? ''} WHERE id = ?`, entry.id)[0];
2664
+ : this.#all(`SELECT * FROM ${TABLE_BY_ENTITY[entry.type] ?? ''} WHERE ${entry.type === 'agent_plan' ? 'agent_id' : 'id'} = ?`, entry.id)[0];
1982
2665
  const ownerId = owner === undefined || row === undefined ? null : row[owner.column];
1983
2666
  return {
1984
2667
  type: entry.type,
@@ -1986,7 +2669,7 @@ export class Store {
1986
2669
  entity_type: entry.type,
1987
2670
  entity_id: entry.id,
1988
2671
  owner_id: typeof ownerId === 'string' ? ownerId : null,
1989
- read_route: readRouteFor(entry.type, entry.id),
2672
+ read_route: readRouteFor(entry.type, entry.id, row),
1990
2673
  revision: typeof row?.revision === 'number' ? row.revision : null,
1991
2674
  change: row === undefined && TABLE_BY_ENTITY[entry.type] !== undefined ? 'deleted' : 'updated',
1992
2675
  ...this.#changeScope(entry.type, row)
@@ -2068,35 +2751,65 @@ export class Store {
2068
2751
  if (input.loadedCapabilities.includes(capabilityId))
2069
2752
  load.run(agentId, capabilityId, timestamp);
2070
2753
  }
2754
+ this.#syncWorkingContext(agentId);
2071
2755
  return this.getAgent(projectId, agentId);
2072
2756
  }
2073
- #copyContextRange(sourceAgentId, targetAgentId, throughOrdinal, timestamp) {
2074
- const boundary = this.#database.prepare(`SELECT MAX(ordinal) AS ordinal FROM context_items WHERE agent_id = ? AND kind = 'compaction' AND ordinal <= ?`).get(sourceAgentId, throughOrdinal);
2075
- const fromOrdinal = boundary.ordinal === null ? 0 : Number(boundary.ordinal);
2076
- const source = this.#all('SELECT * FROM context_items WHERE agent_id = ? AND ordinal >= ? AND ordinal <= ? AND visible = 1 ORDER BY ordinal', sourceAgentId, fromOrdinal, throughOrdinal);
2757
+ #copyContextRange(sourceAgentId, targetAgentId, throughOrdinal, timestamp, selectedIds, working = false) {
2758
+ // Copy archived Full Context too, so checkpoint provenance survives independently of the parent.
2759
+ const selected = selectedIds === undefined ? undefined : new Set(selectedIds);
2760
+ let source = (working
2761
+ ? this.#all(`SELECT c.*, w.content AS content, w.snipped AS snipped FROM working_context_items w
2762
+ JOIN context_items c ON c.id = w.context_item_id WHERE w.agent_id = ? AND c.ordinal <= ? AND c.visible = 1 ORDER BY c.ordinal`, sourceAgentId, throughOrdinal)
2763
+ : this.#all('SELECT * FROM context_items WHERE agent_id = ? AND ordinal <= ? AND visible = 1 ORDER BY ordinal', sourceAgentId, throughOrdinal))
2764
+ .filter((row) => selected === undefined || selected.has(String(row.id)));
2077
2765
  if (source.length === 0 || Number(source.at(-1)?.ordinal) !== throughOrdinal)
2078
2766
  fail('CONTEXT_GROUP_INVALID', 409, 'The Context fork boundary does not exist.');
2079
2767
  const lastGroupId = String(source.at(-1)?.turn_group_id);
2080
- if (this.#count('SELECT COUNT(*) AS count FROM context_items WHERE turn_group_id = ? AND ordinal > ?', lastGroupId, throughOrdinal) !== 0)
2768
+ if (!working && this.#count('SELECT COUNT(*) AS count FROM context_items WHERE turn_group_id = ? AND ordinal > ?', lastGroupId, throughOrdinal) !== 0)
2081
2769
  fail('CONTEXT_GROUP_INVALID', 409, 'Context Fork must end at a complete TurnGroup boundary.');
2770
+ const active = activeContextItems(source.map(doc));
2771
+ const boundaries = assertContextBoundaries(boundaryMessages(active), { allowOpenTail: working, unsettledItemIds: this.#unsettledContextItems(sourceAgentId) });
2772
+ if (working) {
2773
+ // The child inherits observations, never the parent's live execution obligations.
2774
+ const unfinished = new Set(boundaries.protectedRanges.filter((range) => !range.complete)
2775
+ .flatMap((range) => active.slice(range.start, range.end).map((row) => stringField(row.id))));
2776
+ const overrides = new Map(this.#all('SELECT target_kind, target_id, instructions FROM context_overrides WHERE agent_id = ?', sourceAgentId)
2777
+ .map((row) => [`${String(row.target_kind)}:${String(row.target_id)}`, String(row.instructions)]));
2778
+ source = source.map((row) => {
2779
+ const override = overrides.get(`${String(row.kind)}:${String(row.source_id)}`);
2780
+ const content = override === undefined ? row.content : `${override}\n\n${String(row.content)}`;
2781
+ if (!unfinished.has(String(row.id)))
2782
+ return { ...row, content };
2783
+ if (row.kind === 'tool_calls') {
2784
+ const metadata = { ...jsonRecord(parseJson(typeof row.metadata_json === 'string' ? row.metadata_json : null)) };
2785
+ delete metadata.model_response;
2786
+ return { ...row, kind: 'message', role: 'assistant', content: `Unfinished parent tool calls at fork time (historical evidence, not requests to execute):\n${String(row.content)}`, tool_call_id: null, tool_name: null, metadata_json: json(metadata) };
2787
+ }
2788
+ return { ...row, content, tool_call_id: null, tool_name: null };
2789
+ });
2790
+ const instructions = overrides.get(`agent:${sourceAgentId}`);
2791
+ if (instructions !== undefined)
2792
+ this.#insertStandaloneContextMessage(targetAgentId, 'system', instructions, 'message', timestamp);
2793
+ }
2082
2794
  const groupIds = new Map();
2083
2795
  for (const sourceGroupId of new Set(source.map((row) => String(row.turn_group_id)))) {
2084
2796
  const group = this.#require('context_turn_groups', sourceGroupId);
2085
- if (group.state !== 'COMPLETE')
2797
+ if (!working && group.state !== 'COMPLETE')
2086
2798
  fail('CONTEXT_GROUP_INVALID', 409, 'Only complete TurnGroups can be forked.');
2087
2799
  const targetGroupId = id('turn');
2088
2800
  groupIds.set(sourceGroupId, targetGroupId);
2089
2801
  this.#insertTurnGroup(targetGroupId, targetAgentId, String(group.kind), 'COMPLETE', timestamp);
2090
2802
  }
2091
- const insert = this.#database.prepare(`INSERT INTO context_items(id, agent_id, turn_group_id, turn_group_ordinal, kind, source_id, role, content, tool_call_id, tool_name, ordinal, visible, locked, revision, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0, ?, ?)`);
2803
+ const insert = this.#database.prepare(`INSERT INTO context_items(id, agent_id, turn_group_id, turn_group_ordinal, kind, source_id, role, content, tool_call_id, tool_name, ordinal, visible, locked, revision, created_at, updated_at, metadata_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?)`);
2804
+ const contextIds = new Map(source.map((row) => [String(row.id), id('context')]));
2092
2805
  let ordinal = this.#nextOrdinal('context_items', 'agent_id', targetAgentId);
2093
2806
  for (const row of source) {
2094
- const contextId = id('context');
2807
+ const contextId = contextIds.get(String(row.id));
2095
2808
  let sourceId = String(row.source_id);
2096
2809
  if (row.kind === 'message') {
2097
2810
  const messageId = id('message');
2098
- this.#database.prepare(`INSERT INTO messages(id, agent_id, source_agent_id, role, content, metadata_json, created_at) VALUES (?, ?, ?, ?, ?, NULL, ?)`)
2099
- .run(messageId, targetAgentId, sourceAgentId, row.role, row.content, timestamp);
2811
+ this.#database.prepare(`INSERT INTO messages(id, agent_id, source_agent_id, role, content, metadata_json, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`)
2812
+ .run(messageId, targetAgentId, sourceAgentId, row.role, row.content, json({ ingress_origin: 'agent', context_copy: true }), timestamp);
2100
2813
  sourceId = messageId;
2101
2814
  }
2102
2815
  else if (row.kind !== 'result')
@@ -2104,27 +2817,43 @@ export class Store {
2104
2817
  const targetGroupId = groupIds.get(String(row.turn_group_id));
2105
2818
  if (targetGroupId === undefined)
2106
2819
  fail('DATABASE_CORRUPT', 503, 'A Context TurnGroup mapping is missing.');
2107
- insert.run(contextId, targetAgentId, targetGroupId, row.turn_group_ordinal, row.kind, sourceId, row.role, row.content, row.tool_call_id, row.tool_name, ordinal, row.visible, timestamp, timestamp);
2820
+ insert.run(contextId, targetAgentId, targetGroupId, row.turn_group_ordinal, row.kind, sourceId, row.role, row.content, row.tool_call_id, row.tool_name, ordinal, row.visible, row.locked, timestamp, timestamp, row.metadata_json);
2821
+ const coverage = this.#database.prepare('SELECT * FROM context_compactions WHERE context_item_id = ?').get(row.id);
2822
+ if (coverage !== undefined) {
2823
+ const coveredIds = strings(parseJson(String(coverage.covered_item_ids_json))).map((itemId) => contextIds.get(itemId) ?? itemId);
2824
+ this.#database.prepare('INSERT INTO context_compactions(context_item_id, source_revision, covered_item_ids_json, focus) VALUES (?, ?, ?, ?)')
2825
+ .run(contextId, 0, json(coveredIds), coverage.focus);
2826
+ }
2108
2827
  ordinal += 1;
2109
2828
  }
2829
+ this.#assertContext(targetAgentId, false);
2830
+ if (working) {
2831
+ this.#syncWorkingContext(targetAgentId);
2832
+ for (const row of source) {
2833
+ if (Number(row.snipped) === 1)
2834
+ this.#database.prepare('UPDATE working_context_items SET snipped = 1 WHERE context_item_id = ?').run(contextIds.get(String(row.id)));
2835
+ }
2836
+ }
2110
2837
  this.#touchContext(targetAgentId);
2111
2838
  return this.#all('SELECT * FROM context_items WHERE agent_id = ? ORDER BY ordinal', targetAgentId).map(doc);
2112
2839
  }
2113
- #insertStandaloneContextMessage(agentId, role, content, kind, timestamp) {
2840
+ #insertStandaloneContextMessage(agentId, role, content, kind, timestamp, metadata = null) {
2114
2841
  const messageId = id('message');
2115
2842
  const turnGroupId = id('turn');
2116
2843
  const contextId = id('context');
2117
- this.#database.prepare(`INSERT INTO messages(id, agent_id, source_agent_id, role, content, metadata_json, created_at) VALUES (?, ?, NULL, ?, ?, NULL, ?)`)
2118
- .run(messageId, agentId, role, content, timestamp);
2844
+ this.#database.prepare(`INSERT INTO messages(id, agent_id, source_agent_id, role, content, metadata_json, created_at) VALUES (?, ?, NULL, ?, ?, ?, ?)`)
2845
+ .run(messageId, agentId, role, content, metadata === null ? null : json(metadata), timestamp);
2119
2846
  this.#insertTurnGroup(turnGroupId, agentId, kind === 'compaction' ? 'compaction' : 'turn', 'COMPLETE', timestamp);
2120
2847
  this.#database.prepare(`INSERT INTO context_items(id, agent_id, turn_group_id, turn_group_ordinal, kind, source_id, role, content, ordinal, visible, locked, revision, created_at, updated_at) VALUES (?, ?, ?, 0, ?, ?, ?, ?, ?, 1, 0, 0, ?, ?)`)
2121
2848
  .run(contextId, agentId, turnGroupId, kind, messageId, role, content, this.#nextOrdinal('context_items', 'agent_id', agentId), timestamp, timestamp);
2849
+ if (metadata !== null)
2850
+ this.#database.prepare('UPDATE context_items SET metadata_json = ? WHERE id = ?').run(json(metadata), contextId);
2122
2851
  this.#touchContext(agentId);
2123
2852
  }
2124
2853
  // Returns what the caller still has to clean up outside the transaction: Artifact files on disk,
2125
2854
  // and the Calls whose file history the Executor is still holding. Neither can happen in here --
2126
2855
  // Store transactions do no file or network I/O.
2127
- #purgeTaskRows(taskIds) {
2856
+ #purgeTaskRows(projectId, taskIds, executorStateId) {
2128
2857
  for (const taskId of taskIds) {
2129
2858
  const references = this.#count(`SELECT COUNT(*) AS count FROM context_items ci JOIN agents target ON target.id = ci.agent_id WHERE ci.kind = 'result' AND target.task_id != ? AND ci.source_id IN (SELECT r.id FROM results r JOIN calls c ON c.id = r.call_id WHERE c.agent_id IN (SELECT id FROM agents WHERE task_id = ?))`, taskId, taskId);
2130
2859
  if (references > 0)
@@ -2133,6 +2862,8 @@ export class Store {
2133
2862
  const placeholders = taskIds.map(() => '?').join(',') || "''";
2134
2863
  const purgedCalls = this.#all(`SELECT id, workspace_id FROM calls WHERE agent_id IN (SELECT id FROM agents WHERE task_id IN (${placeholders}))`, ...taskIds)
2135
2864
  .map((row) => ({ call_id: String(row.id), workspace_id: String(row.workspace_id) }));
2865
+ if (purgedCalls.length > 0 && !executorStateId)
2866
+ fail('EXECUTOR_IDENTITY_REQUIRED', 503, 'Executor state identity is required before purge.');
2136
2867
  const candidateArtifacts = this.#all(`
2137
2868
  SELECT DISTINCT a.id, a.relative_path FROM artifacts a WHERE a.id IN (
2138
2869
  SELECT ra.artifact_id FROM result_artifacts ra JOIN results r ON r.id = ra.result_id JOIN calls c ON c.id = r.call_id JOIN agents owner ON owner.id = c.agent_id WHERE owner.task_id IN (${placeholders})
@@ -2154,7 +2885,12 @@ export class Store {
2154
2885
  if (this.#count('SELECT COUNT(*) AS count FROM artifacts WHERE relative_path = ?', relativePath) === 0)
2155
2886
  orphanedPaths.add(relativePath);
2156
2887
  }
2157
- return { orphaned_artifact_paths: [...orphanedPaths], purged_calls: purgedCalls };
2888
+ const payload = { orphaned_artifact_paths: [...orphanedPaths], purged_calls: purgedCalls };
2889
+ if (orphanedPaths.size > 0 || purgedCalls.length > 0) {
2890
+ this.#database.prepare('INSERT INTO purge_cleanups(id, project_id, executor_state_id, payload_json, created_at) VALUES (?, ?, ?, ?, ?)')
2891
+ .run(id('purge'), projectId, executorStateId, json(payload), now());
2892
+ }
2893
+ return payload;
2158
2894
  }
2159
2895
  #insertContextMessage(agentId, role, content, ids, timestamp, groupKind = 'included') {
2160
2896
  this.#database.prepare(`INSERT INTO messages(id, agent_id, source_agent_id, role, content, metadata_json, created_at) VALUES (?, ?, NULL, ?, ?, NULL, ?)`)
@@ -2171,24 +2907,25 @@ export class Store {
2171
2907
  if (sourceAgentId !== undefined) {
2172
2908
  const source = this.#require('agents', sourceAgentId);
2173
2909
  this.#ownedTask(String(source.task_id), projectId);
2174
- if (source.task_id !== agent.task_id)
2175
- fail('AGENT_NOT_FOUND', 404, 'Cross-Agent Messages must remain inside one Task.');
2910
+ if (source.session_id !== agent.session_id)
2911
+ fail('AGENT_NOT_FOUND', 404, 'Cross-Agent Messages must remain inside one Session.');
2176
2912
  }
2177
2913
  const pendingOrdinal = this.#nextOrdinal('pending_messages', 'agent_id', agentId);
2178
- const contextOrdinal = this.#nextOrdinal('context_items', 'agent_id', agentId);
2179
- this.#database.prepare(`INSERT INTO messages(id, agent_id, source_agent_id, role, content, metadata_json, created_at) VALUES (?, ?, ?, 'user', ?, NULL, ?)`)
2180
- .run(ids.messageId, agentId, sourceAgentId ?? null, content, timestamp);
2914
+ this.#database.prepare(`INSERT INTO messages(id, agent_id, source_agent_id, role, content, metadata_json, created_at) VALUES (?, ?, ?, 'user', ?, ?, ?)`)
2915
+ .run(ids.messageId, agentId, sourceAgentId ?? null, content, json({ ingress_origin: sourceAgentId === undefined ? 'user' : 'agent' }), timestamp);
2181
2916
  this.#database.prepare(`INSERT INTO pending_messages(id, agent_id, message_id, source_agent_id, content, ordinal, revision, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?)`)
2182
2917
  .run(ids.pendingId, agentId, ids.messageId, sourceAgentId ?? null, content, pendingOrdinal, timestamp, timestamp);
2183
- this.#insertTurnGroup(ids.turnGroupId, agentId, 'turn', 'OPEN', timestamp);
2184
- this.#database.prepare(`INSERT INTO context_items(id, agent_id, turn_group_id, turn_group_ordinal, kind, source_id, role, content, ordinal, visible, locked, revision, created_at, updated_at) VALUES (?, ?, ?, 0, 'message', ?, 'user', ?, ?, 1, 0, 0, ?, ?)`)
2185
- .run(ids.contextId, agentId, ids.turnGroupId, ids.messageId, content, contextOrdinal, timestamp, timestamp);
2918
+ if (agent.state === 'IDLE' || agent.state === 'STOPPED') {
2919
+ this.#insertTurnGroup(ids.turnGroupId, agentId, 'turn', 'OPEN', timestamp);
2920
+ this.#database.prepare(`INSERT INTO context_items(id, agent_id, turn_group_id, turn_group_ordinal, kind, source_id, role, content, ordinal, visible, locked, revision, created_at, updated_at) VALUES (?, ?, ?, 0, 'message', ?, 'user', ?, ?, 1, 0, 0, ?, ?)`)
2921
+ .run(ids.contextId, agentId, ids.turnGroupId, ids.messageId, content, this.#nextOrdinal('context_items', 'agent_id', agentId), timestamp, timestamp);
2922
+ this.#touchContext(agentId);
2923
+ }
2186
2924
  const active = this.#database.prepare(`SELECT * FROM runs WHERE id = ? AND agent_id = ? AND state != 'TERMINAL'`).get(ids.runId, agentId);
2187
2925
  if (active === undefined) {
2188
2926
  this.#database.prepare(`INSERT INTO runs(id, agent_id, goal_message_ids_json, state, outcome, error_code, revision, created_at, updated_at) VALUES (?, ?, '[]', 'READY', NULL, NULL, 0, ?, ?)`)
2189
2927
  .run(ids.runId, agentId, timestamp, timestamp);
2190
2928
  }
2191
- this.#touchContext(agentId);
2192
2929
  return {
2193
2930
  message: this.getMessage(projectId, ids.messageId),
2194
2931
  pending: doc(this.#require('pending_messages', ids.pendingId)),
@@ -2201,7 +2938,12 @@ export class Store {
2201
2938
  }
2202
2939
  #workspace(row) {
2203
2940
  const paths = this.#all('SELECT path FROM workspace_paths WHERE workspace_id = ? ORDER BY ordinal', String(row.id)).map((entry) => String(entry.path));
2204
- return { ...doc(row), paths };
2941
+ const value = { ...doc(row) };
2942
+ delete value.executor_ref;
2943
+ return { ...value, executor_id: typeof row.executor_ref === 'string' ? row.executor_ref : String(row.executor_id), paths };
2944
+ }
2945
+ workspaceOwner(workspaceId) {
2946
+ return { projectId: String(this.#require('workspaces', workspaceId).project_id) };
2205
2947
  }
2206
2948
  /**
2207
2949
  * 02A `AgentSummary`. `is_subagent` and `subagent_list` are derived rather than stored, per the
@@ -2232,9 +2974,49 @@ export class Store {
2232
2974
  this.#database.prepare(`INSERT INTO context_turn_groups(id, agent_id, ordinal, kind, state, revision, created_at, completed_at) VALUES (?, ?, ?, ?, ?, 0, ?, ?)`)
2233
2975
  .run(idValue, agentId, ordinal, kind, state, timestamp, state === 'OPEN' ? null : timestamp);
2234
2976
  }
2977
+ #unsettledContextItems(agentId) {
2978
+ // Follow Result -> Call provenance, including copied results in forked histories.
2979
+ return this.#all(`SELECT i.id FROM context_items i LEFT JOIN results r ON r.id = i.source_id
2980
+ LEFT JOIN calls c ON c.id = r.call_id WHERE i.agent_id = ? AND i.kind = 'result'
2981
+ AND i.tool_call_id IS NOT NULL AND (c.id IS NULL OR c.state != 'TERMINAL'
2982
+ OR c.provider_call_id IS NULL OR c.provider_call_id != i.tool_call_id OR c.capability_id != i.tool_name)`, agentId).map((row) => String(row.id));
2983
+ }
2984
+ #assertContext(agentId, allowOpenTail = true) {
2985
+ const items = activeContextItems(this.#all('SELECT * FROM context_items WHERE agent_id = ? ORDER BY ordinal, id', agentId).map(doc));
2986
+ const messages = boundaryMessages(items);
2987
+ assertContextBoundaries(messages, { allowOpenTail, unsettledItemIds: this.#unsettledContextItems(agentId) });
2988
+ return messages;
2989
+ }
2235
2990
  #touchContext(agentId) {
2236
2991
  this.#database.prepare('UPDATE agents SET context_revision = context_revision + 1, revision = revision + 1, updated_at = ? WHERE id = ?')
2237
2992
  .run(now(), agentId);
2993
+ this.#syncWorkingContext(agentId);
2994
+ }
2995
+ #workingItems(agentId) {
2996
+ return this.#all(`SELECT c.*, w.content AS content, w.snipped AS snipped FROM working_context_items w
2997
+ JOIN context_items c ON c.id = w.context_item_id WHERE w.agent_id = ? ORDER BY c.ordinal, c.id`, agentId).map(doc);
2998
+ }
2999
+ #syncWorkingContext(agentId) {
3000
+ const active = activeContextItems(this.#all('SELECT * FROM context_items WHERE agent_id = ? ORDER BY ordinal, id', agentId).map(doc));
3001
+ const ids = new Set(active.map((item) => stringField(item.id)));
3002
+ const remove = this.#database.prepare('DELETE FROM working_context_items WHERE context_item_id = ?');
3003
+ for (const row of this.#all('SELECT context_item_id FROM working_context_items WHERE agent_id = ?', agentId)) {
3004
+ if (!ids.has(String(row.context_item_id)))
3005
+ remove.run(row.context_item_id);
3006
+ }
3007
+ const upsert = this.#database.prepare(`INSERT INTO working_context_items(context_item_id, agent_id, source_revision, content) VALUES (?, ?, ?, ?)
3008
+ ON CONFLICT(context_item_id) DO UPDATE SET source_revision = excluded.source_revision, content = excluded.content, snipped = 0
3009
+ WHERE working_context_items.source_revision != excluded.source_revision`);
3010
+ for (const item of active)
3011
+ upsert.run(item.id, agentId, item.revision, item.content);
3012
+ const sourceRevision = Number(this.#require('agents', agentId).context_revision);
3013
+ const current = this.#database.prepare('SELECT source_revision, prefixes_json FROM working_contexts WHERE agent_id = ?').get(agentId);
3014
+ if (current?.source_revision === sourceRevision)
3015
+ return;
3016
+ this.#database.prepare(`INSERT INTO working_contexts(agent_id, source_revision, revision, digest, updated_at) VALUES (?, ?, 0, ?, ?)
3017
+ ON CONFLICT(agent_id) DO UPDATE SET source_revision = excluded.source_revision, revision = working_contexts.revision + 1,
3018
+ digest = excluded.digest, messages_json = NULL, request_frame_json = NULL, updated_at = excluded.updated_at`)
3019
+ .run(agentId, sourceRevision, digest({ items: this.#workingItems(agentId), prefixes: current === undefined ? [] : parseJson(String(current.prefixes_json)) }), now());
2238
3020
  }
2239
3021
  #activeProject(projectId) {
2240
3022
  if (this.#require('projects', projectId).archived_at !== null)
@@ -2328,6 +3110,8 @@ function doc(row) {
2328
3110
  fail('DATABASE_CORRUPT', 503, 'A stored JSON field is invalid.');
2329
3111
  result[key.slice(0, -5)] = parseJson(value);
2330
3112
  }
3113
+ else if (key === 'auto_review_passed' && value === null)
3114
+ result[key] = null;
2331
3115
  else if (booleans.has(key))
2332
3116
  result[key] = Number(value) === 1;
2333
3117
  else if (value === null || typeof value === 'string' || typeof value === 'number')
@@ -2337,13 +3121,15 @@ function doc(row) {
2337
3121
  }
2338
3122
  return result;
2339
3123
  }
2340
- const booleans = new Set(['is_default', 'visible', 'locked', 'complete', 'loaded', 'truncated', 'outcome_unknown', 'auto_review_passed', 'initial_step_pending', 'existed_before']);
3124
+ const booleans = new Set(['is_default', 'visible', 'locked', 'complete', 'loaded', 'truncated', 'snipped', 'outcome_unknown', 'auto_review_passed', 'initial_step_pending', 'existed_before']);
2341
3125
  /** The table each published entity type lives in, used to resolve its owner and revision. */
2342
3126
  const TABLE_BY_ENTITY = {
2343
3127
  project: 'projects', workspace: 'workspaces', task: 'tasks', session: 'sessions', agent: 'agents',
2344
3128
  pending_message: 'pending_messages', run: 'runs', step: 'steps', model_attempt: 'model_attempts',
2345
3129
  call: 'calls', message: 'messages', context_item: 'context_items',
2346
- context_override: 'context_overrides', result: 'results', artifact: 'artifacts'
3130
+ context_override: 'context_overrides', result: 'results', artifact: 'artifacts',
3131
+ call_partition: 'call_partitions', turn_group: 'context_turn_groups', projection_decision: 'projection_decisions',
3132
+ agent_plan: 'agent_plans', side_effect_ledger: 'side_effect_ledgers'
2347
3133
  };
2348
3134
  /** The column naming each entity's owner, which a subscriber uses to refresh the owning view. */
2349
3135
  const OWNERSHIP = {
@@ -2351,12 +3137,23 @@ const OWNERSHIP = {
2351
3137
  agent: { column: 'session_id' }, pending_message: { column: 'agent_id' }, run: { column: 'agent_id' },
2352
3138
  step: { column: 'run_id' }, model_attempt: { column: 'step_id' }, call: { column: 'step_id' },
2353
3139
  message: { column: 'agent_id' }, context_item: { column: 'agent_id' },
2354
- context_override: { column: 'agent_id' }, result: { column: 'call_id' }, artifact: { column: 'project_id' }
3140
+ context_override: { column: 'agent_id' }, result: { column: 'call_id' }, artifact: { column: 'project_id' },
3141
+ call_partition: { column: 'step_id' }, turn_group: { column: 'agent_id' }, projection_decision: { column: 'agent_id' },
3142
+ agent_plan: { column: 'agent_id' }, side_effect_ledger: { column: 'step_id' }
2355
3143
  };
2356
3144
  /** The Route a subscriber re-reads for a changed entity. */
2357
- function readRouteFor(type, entityId) {
3145
+ function readRouteFor(type, entityId, row) {
3146
+ if (row === undefined && ['workspace', 'call_partition', 'turn_group', 'projection_decision', 'side_effect_ledger'].includes(type))
3147
+ return '/api/v1/snapshot';
2358
3148
  const routes = {
2359
3149
  project: `/api/v1/projects/${entityId}`,
3150
+ workspace: `/api/v1/projects/${String(row?.project_id)}/workspaces`,
3151
+ call_partition: `/api/v1/steps/${String(row?.step_id)}/partitions`,
3152
+ turn_group: `/api/v1/agents/${String(row?.agent_id)}/context/turn-groups`,
3153
+ projection_decision: `/api/v1/agents/${String(row?.agent_id)}/context/projection/decisions`,
3154
+ agent_plan: `/api/v1/agents/${entityId}`,
3155
+ side_effect_ledger: `/api/v1/steps/${String(row?.step_id)}`,
3156
+ maintenance: '/api/v1/snapshot',
2360
3157
  task: `/api/v1/tasks/${entityId}`,
2361
3158
  session: `/api/v1/sessions/${entityId}`,
2362
3159
  agent: `/api/v1/agents/${entityId}`,
@@ -2413,7 +3210,8 @@ function attentionItems(calls) {
2413
3210
  */
2414
3211
  const SCOPE_PARENT = {
2415
3212
  step: { type: 'run', table: 'runs', column: 'run_id' },
2416
- partition: { type: 'step', table: 'steps', column: 'step_id' },
3213
+ call_partition: { type: 'step', table: 'steps', column: 'step_id' },
3214
+ side_effect_ledger: { type: 'step', table: 'steps', column: 'step_id' },
2417
3215
  result: { type: 'call', table: 'calls', column: 'call_id' }
2418
3216
  };
2419
3217
  /**