@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,6 +1,7 @@
1
1
  import { LensError, UserInputRequiredError, fail } from '../core/domain.js';
2
2
  import { McpElicitationError } from '../adapters/mcp.js';
3
- import { normalizeOutput } from './results.js';
3
+ import { storeOutput } from './results.js';
4
+ import { digest } from '../core/codec.js';
4
5
  export class CallRuntime {
5
6
  store;
6
7
  registry;
@@ -9,6 +10,8 @@ export class CallRuntime {
9
10
  events;
10
11
  #running = new Map();
11
12
  #controllers = new Map();
13
+ #closed = false;
14
+ #failure;
12
15
  constructor(store, registry, scheduler, artifacts, events) {
13
16
  this.store = store;
14
17
  this.registry = registry;
@@ -17,13 +20,27 @@ export class CallRuntime {
17
20
  this.events = events;
18
21
  }
19
22
  dispatch(projectId, callId, parentSignal) {
23
+ if (this.#closed)
24
+ fail('CALL_RUNTIME_CLOSED', 503, 'The Call runtime is closed.');
20
25
  if (this.#running.has(callId))
21
26
  return;
22
27
  const controller = new AbortController();
23
- parentSignal?.addEventListener('abort', () => controller.abort(parentSignal.reason), { once: true });
28
+ const abort = () => controller.abort(parentSignal?.reason);
29
+ if (parentSignal?.aborted === true)
30
+ abort();
31
+ else
32
+ parentSignal?.addEventListener('abort', abort, { once: true });
24
33
  this.#controllers.set(callId, controller);
25
34
  const running = this.#execute(projectId, callId, controller.signal)
35
+ .catch((error) => {
36
+ this.#failure ??= error;
37
+ this.#closed = true;
38
+ for (const active of this.#controllers.values())
39
+ active.abort(error);
40
+ this.store.observability?.record('runtime', 'call_dispatch', 'failed', 0, { error_code: errorCode(error) });
41
+ })
26
42
  .finally(() => {
43
+ parentSignal?.removeEventListener('abort', abort);
27
44
  this.#running.delete(callId);
28
45
  this.#controllers.delete(callId);
29
46
  });
@@ -36,72 +53,173 @@ export class CallRuntime {
36
53
  return decision;
37
54
  }
38
55
  async waitAll(projectId, callIds, signal) {
39
- while (!signal.aborted) {
56
+ return this.events.waitFor(projectId, () => {
40
57
  const calls = callIds.map((callId) => this.store.getCall(projectId, callId));
41
- if (calls.every((call) => call.state === 'TERMINAL'))
42
- return calls;
43
- await new Promise((resolve) => setTimeout(resolve, 25));
44
- }
45
- throw signal.reason;
58
+ return calls.every((call) => call.state === 'TERMINAL') ? calls : undefined;
59
+ }, signal);
46
60
  }
47
- async close() {
61
+ async close(projectId) {
62
+ this.#closed = true;
63
+ if (projectId !== undefined) {
64
+ try {
65
+ this.store.interruptProjectCalls(projectId);
66
+ }
67
+ catch (error) {
68
+ this.#failure ??= error;
69
+ }
70
+ }
48
71
  for (const controller of this.#controllers.values())
49
72
  controller.abort(new Error('Call runtime closed.'));
50
73
  await Promise.allSettled(this.#running.values());
74
+ if (this.#failure !== undefined) {
75
+ throw this.#failure instanceof Error ? this.#failure : new Error('The Call runtime failed.', { cause: this.#failure });
76
+ }
51
77
  }
52
78
  async #execute(projectId, callId, signal) {
53
- let call = this.store.getCall(projectId, callId);
54
- if (call.state !== 'QUEUED')
55
- return;
56
79
  let acquired = false;
80
+ let unsubscribeParent;
57
81
  try {
58
- const capabilityId = string(call.capability_id);
59
- const definition = this.registry.get(capabilityId);
60
- // Every Workspace in the Project, the Call's own first. A Project is one working set: an
61
- // Agent bound to one folder can still read and search the others, which is what makes a Skill
62
- // in one Workspace able to act on data in another.
63
- const workspacePaths = this.store.projectWorkspacePaths(projectId, string(call.workspace_id));
64
- const argumentsValue = jsonValue(call.input);
65
- const validationArguments = originalArguments(argumentsValue);
66
- const claims = this.registry.claims(capabilityId, { projectId, workspaceId: string(call.workspace_id), workspacePaths, arguments: validationArguments });
67
- const executionSignal = await this.scheduler.acquire(callId, definition.adapterId, optionalString(call.step_id) ?? null, claims, () => { call = this.store.startCall(projectId, callId); }, signal);
68
- acquired = true;
69
- const output = await this.registry.execute({
70
- callId,
71
- projectId,
72
- agentId: optionalString(call.agent_id) ?? null,
73
- capabilityId,
74
- arguments: capabilityId === 'ask_user' || capabilityId.startsWith('mcp:') ? argumentsValue : validationArguments,
75
- workspacePaths,
76
- workspaceId: string(call.workspace_id),
77
- ...(call.policy === 'full_access' ? { fullAccess: true } : {}),
78
- signal: executionSignal
79
- }, validationArguments);
80
- const normalized = normalizeOutput(output, definition.outputLimitBytes);
81
- const artifactIds = await this.artifacts.save(projectId, normalized.artifacts);
82
- if (this.store.getCall(projectId, callId).state === 'RUNNING') {
83
- const outcome = output.outcome ?? 'success';
84
- this.store.completeCall(projectId, callId, outcome, normalized.content, outcome === 'failed' ? output.errorCode ?? 'CAPABILITY_FAILED' : null, null, artifactIds, normalized.truncated);
82
+ let call = this.store.getCall(projectId, callId);
83
+ if (call.state !== 'QUEUED')
84
+ return;
85
+ if (typeof call.parent_call_id === 'string') {
86
+ const parentId = call.parent_call_id;
87
+ unsubscribeParent = this.events.onProjectChange(projectId, () => {
88
+ try {
89
+ if (this.store.getCall(projectId, parentId).state !== 'RUNNING') {
90
+ this.#controllers.get(callId)?.abort(new Error('The parent Call stopped.'));
91
+ }
92
+ }
93
+ catch (error) {
94
+ this.#controllers.get(callId)?.abort(error);
95
+ }
96
+ });
97
+ }
98
+ for (;;) {
99
+ call = await this.events.waitFor(projectId, () => {
100
+ const current = this.store.getCall(projectId, callId);
101
+ if (current.state !== 'QUEUED')
102
+ return current;
103
+ const admission = this.store.callAdmission(projectId, callId);
104
+ this.#validateCall(projectId, current);
105
+ return admission === 'blocked' ? undefined : current;
106
+ }, signal);
107
+ if (call.state !== 'QUEUED')
108
+ return;
109
+ const capabilityId = string(call.capability_id);
110
+ const definition = this.registry.get(capabilityId);
111
+ // Every Workspace in the Project, the Call's own first. A Project is one working set: an
112
+ // Agent bound to one folder can still read and search the others, which is what makes a Skill
113
+ // in one Workspace able to act on data in another.
114
+ const workspacePaths = this.store.projectWorkspacePaths(projectId, string(call.workspace_id));
115
+ const argumentsValue = jsonValue(call.input);
116
+ const validationArguments = argumentsValue;
117
+ const continuation = call.continuation;
118
+ const claims = this.registry.claims(capabilityId, { projectId, workspaceId: string(call.workspace_id), workspacePaths, arguments: validationArguments });
119
+ let executionSignal = signal;
120
+ signal.throwIfAborted();
121
+ const start = () => {
122
+ call = this.store.startCall(projectId, callId, (current) => {
123
+ signal.throwIfAborted();
124
+ this.#validateCall(projectId, current);
125
+ });
126
+ };
127
+ try {
128
+ if (capabilityId === 'programmatic_tool_run' || capabilityId === 'subagent_wait') {
129
+ // Coordinators must not occupy a tool lease while waiting for other work.
130
+ start();
131
+ }
132
+ else {
133
+ executionSignal = await this.scheduler.acquire(callId, definition.adapterId, optionalString(call.step_id) ?? null, claims, start, signal);
134
+ acquired = true;
135
+ }
136
+ }
137
+ catch (error) {
138
+ if (!signal.aborted && error instanceof LensError && error.code === 'CALL_BLOCKED')
139
+ continue;
140
+ throw error;
141
+ }
142
+ const output = await this.registry.execute({
143
+ callId,
144
+ ...(typeof call.agent_id === 'string' ? { taskId: string(this.store.getAgent(projectId, call.agent_id).task_id) } : {}),
145
+ projectId,
146
+ agentId: optionalString(call.agent_id) ?? null,
147
+ capabilityId,
148
+ arguments: argumentsValue,
149
+ ...(continuation == null ? {} : { continuation }),
150
+ workspacePaths,
151
+ workspaceId: string(call.workspace_id),
152
+ ...(call.policy === 'full_access' ? { fullAccess: true } : {}),
153
+ signal: executionSignal
154
+ }, validationArguments);
155
+ if (signal.aborted) {
156
+ this.store.interruptCall(projectId, callId, 'CALL_CANCELLED', 'The Call was stopped and will not be resumed.');
157
+ return;
158
+ }
159
+ if (this.store.getCall(projectId, callId).state !== 'RUNNING')
160
+ return;
161
+ // Local program calls return their raw content to the program, not a Context preview.
162
+ const normalized = call.source === 'program' || capabilityId === 'programmatic_tool_run'
163
+ ? (output.artifacts?.length ?? 0) + (output.artifactReferences?.length ?? 0) > 0
164
+ ? await storeOutput(projectId, output, this.artifacts, false)
165
+ : { content: output.content, artifacts: [], truncated: false }
166
+ : await storeOutput(projectId, output, this.artifacts);
167
+ if (this.store.getCall(projectId, callId).state === 'RUNNING') {
168
+ const outcome = output.outcome ?? 'success';
169
+ this.store.completeCall(projectId, callId, outcome, normalized.content, outcome === 'failed' ? output.errorCode ?? 'CAPABILITY_FAILED' : null, null, normalized.artifacts, normalized.truncated, output.capture ?? 'unknown', 'full');
170
+ }
171
+ return;
85
172
  }
86
173
  }
87
174
  catch (error) {
88
175
  let current = this.store.getCall(projectId, callId);
89
- if (current.state === 'QUEUED')
90
- current = this.store.startCall(projectId, callId);
91
- if (current.state === 'RUNNING' && (error instanceof McpElicitationError || error instanceof UserInputRequiredError)) {
176
+ if (signal.aborted) {
177
+ this.store.interruptCall(projectId, callId, 'CALL_CANCELLED', 'The Call was stopped and will not be resumed.');
178
+ }
179
+ else if (current.state === 'QUEUED') {
180
+ this.store.failQueuedCall(projectId, callId, errorCode(error), error instanceof Error ? error.message : String(error), current.capability_id === 'programmatic_tool_run' || current.source === 'program'
181
+ ? { error_code: errorCode(error), error_message: error instanceof Error ? error.message : String(error) } : {});
182
+ }
183
+ else if (current.state === 'RUNNING' && (error instanceof McpElicitationError || error instanceof UserInputRequiredError)) {
92
184
  current = this.store.waitCall(projectId, callId, error.prompt);
93
185
  this.events.emit(projectId, this.store.stateRevision(), 'attention.required', { code: 'CALL_WAITING_USER', entity_type: 'call', entity_id: callId, agent_id: current.agent_id ?? null });
94
186
  }
95
187
  else if (current.state === 'RUNNING') {
96
- const cancelled = signal.aborted;
97
- this.store.completeCall(projectId, callId, cancelled ? 'cancelled' : 'failed', {}, cancelled ? 'CALL_CANCELLED' : errorCode(error), error instanceof Error ? error.message : String(error));
188
+ this.store.completeCall(projectId, callId, 'failed', current.capability_id === 'programmatic_tool_run' ? { error_code: errorCode(error), error_message: error instanceof Error ? error.message : String(error) } : {}, errorCode(error), error instanceof Error ? error.message : String(error));
98
189
  }
99
190
  }
100
191
  finally {
192
+ unsubscribeParent?.();
101
193
  if (acquired)
102
194
  this.scheduler.release(callId);
103
195
  }
104
196
  }
197
+ #validateCall(projectId, call) {
198
+ if (typeof call.attempt_id !== 'string' || typeof call.agent_id !== 'string')
199
+ return;
200
+ const capabilityId = string(call.capability_id);
201
+ const attempt = this.store.getAttempt(projectId, call.attempt_id);
202
+ if (attempt.agent_id !== call.agent_id || attempt.step_id !== call.step_id) {
203
+ fail('CALL_ATTEMPT_INVALID', 409, 'The Call does not belong to its model attempt.');
204
+ }
205
+ const capabilities = Array.isArray(attempt.capabilities) ? attempt.capabilities : [];
206
+ const frozen = capabilities.find(entry => jsonObject(entry) && entry.capability_id === capabilityId);
207
+ if (frozen === undefined || !jsonObject(frozen)) {
208
+ fail('CAPABILITY_NOT_LOADED', 403, 'The Capability is outside the frozen model attempt.');
209
+ }
210
+ const definition = this.registry.get(capabilityId);
211
+ if (digest(frozen.schema ?? null) !== digest(definition.inputSchema)) {
212
+ fail('CAPABILITY_MANIFEST_CHANGED', 409, 'The Capability input Schema changed after the model attempt.');
213
+ }
214
+ const assignment = this.store.getCapabilityAssignment(projectId, call.agent_id, capabilityId);
215
+ if (assignment.loaded !== true)
216
+ fail('CAPABILITY_NOT_LOADED', 403, 'The Capability is no longer loaded.');
217
+ if (assignment.policy !== call.policy)
218
+ fail('CALL_POLICY_CHANGED', 403, 'The Capability policy changed after the Call was authorized.');
219
+ if (this.registry.effect(capabilityId, jsonValue(call.input)) !== call.effect) {
220
+ fail('CAPABILITY_MANIFEST_CHANGED', 409, 'The Capability effect changed after the Call was authorized.');
221
+ }
222
+ }
105
223
  }
106
224
  function errorCode(error) {
107
225
  if (error instanceof LensError)
@@ -123,8 +241,6 @@ function jsonValue(value) {
123
241
  fail('DATABASE_CORRUPT', 503, 'Call input is missing.');
124
242
  return value;
125
243
  }
126
- function originalArguments(value) {
127
- if (typeof value !== 'object' || value === null || Array.isArray(value))
128
- return value;
129
- return 'arguments' in value && ('answer' in value || 'prompt' in value) ? value.arguments : value;
244
+ function jsonObject(value) {
245
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
130
246
  }
@@ -45,12 +45,12 @@ export declare class CapabilityRegistry {
45
45
  * values for the same global field, and the handshake check comparing readiness against
46
46
  * `subscription.ready` could never pass for a Project whose Skills differed from the default
47
47
  * one's: a permanent resync loop rather than a transient mismatch. A changed Skill is still
48
- * visible through `metadata_revision` and the Agent's Assignment digest.
48
+ * visible through `project_capabilities_digest` and Project-scoped Snapshot invalidation.
49
49
  */
50
50
  contractManifests(): readonly AdapterManifest[];
51
51
  get(capabilityId: string): CapabilityDefinition;
52
52
  find(capabilityId: string): CapabilityDefinition | undefined;
53
- search(query: string, limit: number): readonly CapabilityDefinition[];
53
+ search(query: string): readonly CapabilityDefinition[];
54
54
  claims(capabilityId: string, context: ClaimContext): readonly ResourceClaim[];
55
55
  effect(capabilityId: string, argumentsValue: Json): EffectScope;
56
56
  execute(invocation: CapabilityInvocation, validationArguments?: Json): Promise<CapabilityOutput>;
@@ -76,7 +76,7 @@ export class CapabilityRegistry {
76
76
  * values for the same global field, and the handshake check comparing readiness against
77
77
  * `subscription.ready` could never pass for a Project whose Skills differed from the default
78
78
  * one's: a permanent resync loop rather than a transient mismatch. A changed Skill is still
79
- * visible through `metadata_revision` and the Agent's Assignment digest.
79
+ * visible through `project_capabilities_digest` and Project-scoped Snapshot invalidation.
80
80
  */
81
81
  contractManifests() {
82
82
  const contract = new Set(this.list().filter((definition) => definition.kind !== 'skill').map((definition) => definition.id));
@@ -88,13 +88,12 @@ export class CapabilityRegistry {
88
88
  find(capabilityId) {
89
89
  return this.#entries.get(capabilityId)?.definition;
90
90
  }
91
- search(query, limit) {
91
+ search(query) {
92
92
  const words = query.toLowerCase().split(/\s+/).filter(Boolean);
93
93
  return this.list()
94
94
  .map((entry) => ({ entry, score: words.reduce((score, word) => score + `${entry.id} ${entry.title} ${entry.description}`.toLowerCase().split(word).length - 1, 0) }))
95
95
  .filter(({ score }) => score > 0)
96
96
  .sort((left, right) => right.score - left.score || left.entry.id.localeCompare(right.entry.id))
97
- .slice(0, limit)
98
97
  .map(({ entry }) => entry);
99
98
  }
100
99
  // Any failure to work out the resources falls back to a global write claim: AGENTS.md requires the
@@ -118,10 +117,10 @@ export class CapabilityRegistry {
118
117
  async execute(invocation, validationArguments = invocation.arguments) {
119
118
  const entry = this.#validated(invocation.capabilityId, validationArguments);
120
119
  const output = await entry.adapter.execute(invocation);
121
- if (entry.validateOutput !== undefined) {
120
+ if (entry.validateOutput !== undefined && output.outcome !== 'failed') {
122
121
  const content = jsonObject(output.content);
123
122
  const structured = content?.structured_content;
124
- if (structured !== undefined && !entry.validateOutput(structured)) {
123
+ if (structured === undefined || !entry.validateOutput(structured)) {
125
124
  fail('RESULT_NORMALIZATION_FAILED', 422, 'Capability output does not match the declared Schema.');
126
125
  }
127
126
  }
@@ -1,7 +1,10 @@
1
1
  import type { ExecutorClient } from '../adapters/executor.js';
2
- import type { Document } from '../store/store.js';
2
+ type CleanupExecutor = Pick<ExecutorClient, 'health' | 'deleteCallHistory'>;
3
+ import type { Document, Store } from '../store/store.js';
3
4
  import type { ArtifactStore } from './artifacts.js';
4
- export declare function applyPurge(purged: Document, artifacts: ArtifactStore, executor: ExecutorClient): Promise<{
5
+ export declare function commitPurge(executor: CleanupExecutor, purge: (stateId: string) => Document): Promise<Document>;
6
+ export declare function applyPurge(projectId: string, store: Store, artifacts: ArtifactStore, executor: CleanupExecutor): Promise<{
5
7
  readonly deleted: number;
6
8
  readonly failed: number;
7
9
  }>;
10
+ export {};
@@ -1,21 +1,52 @@
1
- import { fail } from '../core/domain.js';
2
- // The out-of-transaction half of a Task purge. Store#purgeTaskRows deletes the rows and reports what
3
- // is left over: Artifact files on disk, and the Calls whose file history the Executor still holds
4
- // (see docs/architecture/harness-level3/09-file-snapshot-storage-decision.md). Neither can happen
5
- // inside the transaction, which does no file or network I/O.
6
- //
7
- // Errors propagate. The purge has already committed and those Call ids no longer exist in SQLite, so
8
- // a failure here leaks Executor-side history permanently -- the Executor never expires history on
9
- // its own. Surfacing that once is better than hiding it.
10
- export async function applyPurge(purged, artifacts, executor) {
11
- const paths = purged.orphaned_artifact_paths;
12
- if (!Array.isArray(paths) || paths.some((entry) => typeof entry !== 'string'))
13
- fail('DATABASE_CORRUPT', 503, 'Task purge returned invalid Artifact paths.');
14
- const files = await artifacts.remove(paths);
15
- for (const [workspaceId, callIds] of byWorkspace(purged.purged_calls)) {
16
- await executor.deleteCallHistory(workspaceId, callIds);
1
+ import { fail, LensError } from '../core/domain.js';
2
+ // Store rejects a purge needing identity inside its transaction, so nothing is committed or
3
+ // published before this fallback. Running it again also rechecks ownership and revisions.
4
+ export async function commitPurge(executor, purge) {
5
+ try {
6
+ return purge('');
17
7
  }
18
- return files;
8
+ catch (error) {
9
+ if (!(error instanceof LensError) || error.code !== 'EXECUTOR_IDENTITY_REQUIRED')
10
+ throw error;
11
+ }
12
+ return purge(await executorStateId(executor));
13
+ }
14
+ async function executorStateId(executor) {
15
+ const health = await executor.health();
16
+ if (typeof health !== 'object' || health === null || !('state_id' in health) || typeof health.state_id !== 'string' || health.state_id.length === 0) {
17
+ fail('EXECUTOR_IDENTITY_REQUIRED', 503, 'Executor state identity is unavailable; no Task was purged.');
18
+ }
19
+ return health.state_id;
20
+ }
21
+ export async function applyPurge(projectId, store, artifacts, executor) {
22
+ let deleted = 0;
23
+ for (const entry of store.pendingPurges(projectId)) {
24
+ try {
25
+ if (typeof entry.id !== 'string')
26
+ fail('DATABASE_CORRUPT', 503, 'Cleanup identity is invalid.');
27
+ const payload = entry.payload;
28
+ const calls = byWorkspace(payload.purged_calls);
29
+ if (calls.size > 0 && await executorStateId(executor) !== entry.executor_state_id)
30
+ fail('EXECUTOR_STATE_CHANGED', 409, 'The original Executor history store is not connected.');
31
+ const paths = payload.orphaned_artifact_paths;
32
+ if (!Array.isArray(paths) || paths.some((path) => typeof path !== 'string'))
33
+ fail('DATABASE_CORRUPT', 503, 'Task purge returned invalid Artifact paths.');
34
+ const files = await artifacts.remove(paths);
35
+ deleted += files.deleted;
36
+ if (files.failed > 0)
37
+ fail('ARTIFACT_DELETE_FAILED', 503, 'Some Artifact files could not be removed.');
38
+ for (const [workspaceId, callIds] of calls)
39
+ await executor.deleteCallHistory(workspaceId, callIds);
40
+ store.completePurge(projectId, entry.id);
41
+ }
42
+ catch (error) {
43
+ fail('PURGE_INCOMPLETE', 503, 'Logical deletion is committed; cleanup remains pending. Retry the maintenance command.', {
44
+ cleanup_id: entry.id ?? null,
45
+ cause: typeof error === 'object' && error !== null && 'code' in error && typeof error.code === 'string' ? error.code : 'CLEANUP_FAILED'
46
+ });
47
+ }
48
+ }
49
+ return { deleted, failed: 0 };
19
50
  }
20
51
  function byWorkspace(value) {
21
52
  const grouped = new Map();
@@ -0,0 +1,9 @@
1
+ import { type ModelMessage } from '../core/domain.js';
2
+ import type { HarnessConfig } from '../config/schema.js';
3
+ import type { PreparedContext } from './context.js';
4
+ export declare function compactionThreshold(config: HarnessConfig, reference: string, window: number): number;
5
+ /** Keep at least 5000 estimated tokens, rounded outward to whole messages/tool exchanges. */
6
+ export declare function compactionRange(prepared: PreparedContext): {
7
+ readonly ids: readonly string[];
8
+ readonly messages: readonly ModelMessage[];
9
+ };
@@ -0,0 +1,52 @@
1
+ import { assertContextBoundaries } from '../core/context-boundaries.js';
2
+ import { fail } from '../core/domain.js';
3
+ import { estimateRequest } from './tokenizer.js';
4
+ export function compactionThreshold(config, reference, window) {
5
+ const slash = reference.indexOf('/');
6
+ const provider = config.providers[reference.slice(0, slash)];
7
+ const model = provider?.models[reference.slice(slash + 1)];
8
+ return model?.compactionThresholdTokens ?? provider?.compactionThresholdTokens ?? Math.max(window - 25000, Math.ceil(window * 0.95));
9
+ }
10
+ /** Keep at least 5000 estimated tokens, rounded outward to whole messages/tool exchanges. */
11
+ export function compactionRange(prepared) {
12
+ const source = new Map(prepared.source.items.map((item) => [item.id, item]));
13
+ const history = prepared.messages.filter((message) => {
14
+ const item = source.get(message.contextItemId ?? '');
15
+ return item !== undefined && !(item.kind === 'message' && item.role === 'system');
16
+ });
17
+ const boundaries = assertContextBoundaries(history, { allowOpenTail: true, unsettledItemIds: prepared.source.unsettledItemIds ?? [] });
18
+ let retained = 0;
19
+ let naiveCut = history.length;
20
+ while (naiveCut > 0 && retained < 5000) {
21
+ naiveCut -= 1;
22
+ retained += estimateRequest([history[naiveCut]]).expected;
23
+ }
24
+ let cut = boundaries.legalCuts.findLast((position) => position <= naiveCut) ?? 0;
25
+ const legal = new Set(boundaries.legalCuts);
26
+ // Prefer a nearby real turn boundary. Spend at most one additional tail budget;
27
+ // an arbitrarily long business turn must never become a hard atomic group.
28
+ let extraTokens = 0;
29
+ for (let candidate = cut; candidate > 0; candidate -= 1) {
30
+ const next = history[candidate];
31
+ const previous = history[candidate - 1];
32
+ if (next === undefined || previous === undefined)
33
+ break;
34
+ const nextItem = source.get(next.contextItemId ?? '');
35
+ const previousItem = source.get(previous.contextItemId ?? '');
36
+ if (legal.has(candidate) && nextItem?.kind === 'message' && next.role === 'user' &&
37
+ (nextItem.turn_group_id === undefined || nextItem.turn_group_id !== previousItem?.turn_group_id)) {
38
+ cut = candidate;
39
+ break;
40
+ }
41
+ extraTokens += estimateRequest([previous]).expected;
42
+ if (extraTokens > 5000)
43
+ break;
44
+ }
45
+ const covered = history.slice(0, cut);
46
+ if (covered.length === 0)
47
+ fail('COMPACTION_NOT_NEEDED', 409, 'No complete history block remains before the retained 5000-token tail.');
48
+ return {
49
+ ids: covered.map((message) => message.contextItemId),
50
+ messages: [...prepared.messages.filter((message) => message.contextItemId === undefined || (source.get(message.contextItemId)?.kind === 'message' && message.role === 'system')), ...covered]
51
+ };
52
+ }
@@ -1,17 +1,17 @@
1
1
  import { type Json, type ModelMessage } from '../core/domain.js';
2
- import type { Store } from '../store/store.js';
2
+ import type { Store, Document } from '../store/store.js';
3
3
  import type { HarnessConfig } from '../config/schema.js';
4
- import type { Document } from '../store/store.js';
5
4
  import type { ResolvedProfile } from './profile.js';
6
5
  import type { CapabilityRegistry } from './capabilities.js';
7
6
  import { type TokenEstimate } from './tokenizer.js';
8
7
  export interface ContextDecision {
9
- readonly stage: 'tool_result_budget' | 'snip' | 'microcompact' | 'context_collapse';
8
+ readonly stage: 'snip';
10
9
  readonly sourceTokens: number;
11
10
  readonly projectedTokens: number;
12
11
  }
13
12
  export interface PreparedContext {
14
13
  readonly source: ContextSource;
14
+ readonly fullMessages: readonly ModelMessage[];
15
15
  readonly messages: readonly ModelMessage[];
16
16
  readonly estimate: TokenEstimate;
17
17
  readonly sourceRevision: number;
@@ -21,14 +21,18 @@ export interface PreparedContext {
21
21
  export interface ContextSource {
22
22
  readonly agent: Document;
23
23
  readonly items: readonly Document[];
24
+ readonly todo?: Document | null;
25
+ readonly unsettledItemIds?: readonly string[];
26
+ readonly workingItems?: readonly Document[];
24
27
  readonly overrides: readonly Document[];
25
28
  readonly capabilities: readonly Document[];
26
29
  }
27
30
  export declare class ContextEngine {
31
+ #private;
28
32
  readonly store: Store;
29
33
  readonly registry: CapabilityRegistry;
30
34
  constructor(store: Store, registry: CapabilityRegistry);
31
35
  capture(projectId: string, agentId: string): ContextSource;
32
36
  prepare(projectId: string, agentId: string, config: HarnessConfig, contextWindowTokens: number, profile: ResolvedProfile, requestFrame?: Json): PreparedContext;
33
- prepareSource(source: ContextSource, config: HarnessConfig, contextWindowTokens: number, profile: ResolvedProfile, requestFrame?: Json): PreparedContext;
37
+ prepareSource(source: ContextSource, config: HarnessConfig, contextWindowTokens: number, profile: ResolvedProfile, requestFrame?: Json, projectId?: string): PreparedContext;
34
38
  }