@lensagent/core 0.1.1 → 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.
- package/README.md +63 -4
- package/config/lens.example.jsonc +36 -13
- package/dist/adapters/executor-router.d.ts +20 -0
- package/dist/adapters/executor-router.js +86 -0
- package/dist/adapters/executor.d.ts +9 -3
- package/dist/adapters/executor.js +196 -61
- package/dist/adapters/lsp.d.ts +2 -1
- package/dist/adapters/lsp.js +98 -41
- package/dist/adapters/mcp.js +52 -17
- package/dist/adapters/revert.d.ts +5 -4
- package/dist/adapters/revert.js +6 -64
- package/dist/adapters/web.d.ts +9 -0
- package/dist/adapters/web.js +244 -0
- package/dist/config/files.d.ts +14 -0
- package/dist/config/files.js +57 -0
- package/dist/config/runtime.d.ts +3 -4
- package/dist/config/runtime.js +116 -97
- package/dist/config/schema.d.ts +56 -10
- package/dist/config/schema.js +48 -8
- package/dist/contracts/capability.d.ts +27 -1
- package/dist/contracts/domain.d.ts +1 -0
- package/dist/contracts/model.d.ts +33 -6
- package/dist/contracts/review.d.ts +17 -0
- package/dist/contracts/review.js +1 -0
- package/dist/core/access-policy.d.ts +40 -0
- package/dist/core/access-policy.js +68 -0
- package/dist/core/access.d.ts +30 -2
- package/dist/core/access.js +159 -26
- package/dist/core/context-boundaries.d.ts +25 -0
- package/dist/core/context-boundaries.js +106 -0
- package/dist/core/context.d.ts +3 -0
- package/dist/core/context.js +11 -0
- package/dist/core/host.js +261 -173
- package/dist/entry/pair.d.ts +1 -0
- package/dist/entry/pair.js +14 -0
- package/dist/entry/run.js +1 -1
- package/dist/execution/lsp-process.d.ts +3 -2
- package/dist/execution/lsp-process.js +46 -28
- package/dist/http/server.d.ts +5 -0
- package/dist/http/server.js +262 -80
- package/dist/runtime/agent.d.ts +9 -4
- package/dist/runtime/agent.js +361 -286
- package/dist/runtime/artifacts.d.ts +13 -1
- package/dist/runtime/artifacts.js +137 -10
- package/dist/runtime/cache.d.ts +59 -16
- package/dist/runtime/cache.js +124 -43
- package/dist/runtime/calls.d.ts +1 -1
- package/dist/runtime/calls.js +164 -48
- package/dist/runtime/capabilities.d.ts +2 -2
- package/dist/runtime/capabilities.js +4 -5
- package/dist/runtime/cleanup.d.ts +5 -2
- package/dist/runtime/cleanup.js +48 -17
- package/dist/runtime/compaction.d.ts +9 -0
- package/dist/runtime/compaction.js +52 -0
- package/dist/runtime/context.d.ts +8 -4
- package/dist/runtime/context.js +115 -128
- package/dist/runtime/events.d.ts +7 -0
- package/dist/runtime/events.js +83 -0
- package/dist/runtime/images.d.ts +14 -0
- package/dist/runtime/images.js +82 -0
- package/dist/runtime/kernel-capabilities.d.ts +7 -1
- package/dist/runtime/kernel-capabilities.js +146 -68
- package/dist/runtime/model-audit.d.ts +9 -0
- package/dist/runtime/model-audit.js +39 -0
- package/dist/runtime/model.d.ts +109 -4
- package/dist/runtime/model.js +368 -143
- package/dist/runtime/observability.d.ts +1 -0
- package/dist/runtime/observability.js +60 -15
- package/dist/runtime/policy.d.ts +1 -1
- package/dist/runtime/policy.js +2 -2
- package/dist/runtime/profile.d.ts +2 -1
- package/dist/runtime/profile.js +12 -15
- package/dist/runtime/projects.d.ts +3 -1
- package/dist/runtime/projects.js +38 -17
- package/dist/runtime/ptc-worker.d.ts +1 -0
- package/dist/runtime/ptc-worker.js +97 -0
- package/dist/runtime/ptc.d.ts +16 -0
- package/dist/runtime/ptc.js +128 -0
- package/dist/runtime/results.d.ts +5 -4
- package/dist/runtime/results.js +29 -51
- package/dist/runtime/review.d.ts +4 -1
- package/dist/runtime/review.js +101 -22
- package/dist/runtime/scheduler.js +1 -0
- package/dist/runtime/skills.js +34 -5
- package/dist/runtime/task-audit.d.ts +21 -0
- package/dist/runtime/task-audit.js +122 -0
- package/dist/runtime/time.d.ts +9 -0
- package/dist/runtime/time.js +18 -0
- package/dist/runtime/tokenizer.d.ts +2 -0
- package/dist/runtime/tokenizer.js +26 -1
- package/dist/store/database.js +26 -1
- package/dist/store/event-log.d.ts +1 -0
- package/dist/store/event-log.js +22 -5
- package/dist/store/schema.d.ts +1 -1
- package/dist/store/schema.js +50 -1
- package/dist/store/store.d.ts +65 -19
- package/dist/store/store.js +986 -188
- package/package.json +14 -6
- package/proto/executor.proto +69 -55
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { randomBytes } from 'node:crypto';
|
|
2
|
-
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
3
|
-
import { dirname,
|
|
2
|
+
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { dirname, join, resolve } from 'node:path';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
import * as grpc from '@grpc/grpc-js';
|
|
6
6
|
import * as protoLoader from '@grpc/proto-loader';
|
|
@@ -30,18 +30,21 @@ export class ExecutorCallError extends Error {
|
|
|
30
30
|
const executorProtoPath = resolve(dirname(fileURLToPath(import.meta.url)), '../../proto/executor.proto');
|
|
31
31
|
// Set explicitly instead of inheriting @grpc/grpc-js's 4 MiB receive default. Keep in step with the
|
|
32
32
|
// same constant in lens-remote-executor's src/main.ts: the Executor's server-side receive limit is
|
|
33
|
-
// what actually bounds a
|
|
33
|
+
// what actually bounds a file-edit request, so both sides have to agree.
|
|
34
34
|
const maxMessageBytes = 16 * 1024 * 1024;
|
|
35
35
|
// Rejected in Core with a usable message rather than letting the transport fail with
|
|
36
36
|
// RESOURCE_EXHAUSTED. Below maxMessageBytes so the rest of the request always fits.
|
|
37
37
|
const contentLimitBytes = 8 * 1024 * 1024;
|
|
38
38
|
export class ExecutorClient {
|
|
39
39
|
config;
|
|
40
|
+
endpoint;
|
|
40
41
|
id = 'executor';
|
|
41
42
|
#client;
|
|
42
43
|
#metadata = new grpc.Metadata();
|
|
43
|
-
|
|
44
|
+
#sharedPaths = new Set();
|
|
45
|
+
constructor(client, config, endpoint, identity) {
|
|
44
46
|
this.config = config;
|
|
47
|
+
this.endpoint = endpoint;
|
|
45
48
|
this.#client = client;
|
|
46
49
|
if (identity !== undefined) {
|
|
47
50
|
this.#metadata.set('x-lens-client-kind', 'native');
|
|
@@ -49,14 +52,25 @@ export class ExecutorClient {
|
|
|
49
52
|
this.#metadata.set('x-lens-hardware-id', identity.hardwareId);
|
|
50
53
|
}
|
|
51
54
|
}
|
|
52
|
-
static async create(root, runtime) {
|
|
53
|
-
const config =
|
|
55
|
+
static async create(root, runtime, endpoint = runtime.current.config.executor) {
|
|
56
|
+
const config = endpoint;
|
|
54
57
|
const packageDefinition = protoLoader.loadSync(executorProtoPath, { keepCase: true, longs: Number, defaults: true, oneofs: true });
|
|
55
58
|
const loaded = grpc.loadPackageDefinition(packageDefinition);
|
|
56
59
|
const credentials = await channelCredentials(root, config);
|
|
57
60
|
const clientIdentity = config.identityPath === undefined ? undefined : await loadIdentity(resolve(root, config.identityPath));
|
|
58
61
|
const options = { 'grpc.max_receive_message_length': maxMessageBytes, 'grpc.max_send_message_length': maxMessageBytes };
|
|
59
|
-
|
|
62
|
+
const adapter = new ExecutorClient(new loaded.lens.executor.v1.Executor(config.address, credentials, options), runtime, config, clientIdentity);
|
|
63
|
+
if (config.credentialPath !== undefined) {
|
|
64
|
+
if (config.tls?.enabled !== true && !/^(localhost|127\.0\.0\.1|\[::1\]):\d+$/u.test(config.address)) {
|
|
65
|
+
adapter.close();
|
|
66
|
+
throw new Error('Core Executor service credentials require TLS outside loopback.');
|
|
67
|
+
}
|
|
68
|
+
const token = (await readFile(resolve(root, config.credentialPath), 'utf8')).trim();
|
|
69
|
+
if (!/^[A-Za-z0-9_-]{43}$/u.test(token))
|
|
70
|
+
throw new Error('Invalid Core Executor service credential.');
|
|
71
|
+
adapter.#metadata.set('x-lens-core-credential', token);
|
|
72
|
+
}
|
|
73
|
+
return adapter;
|
|
60
74
|
}
|
|
61
75
|
close() {
|
|
62
76
|
this.#client.close();
|
|
@@ -64,6 +78,23 @@ export class ExecutorClient {
|
|
|
64
78
|
health(signal) {
|
|
65
79
|
return this.call('health', {}, 5000, signal);
|
|
66
80
|
}
|
|
81
|
+
async taskAudit(projectId, taskId, limit, cursor) {
|
|
82
|
+
const response = object(await this.call('queryTaskAudit', { project_id: projectId, task_id: taskId, limit, cursor: cursor ?? '' }, 30000));
|
|
83
|
+
if (!Array.isArray(response.events_json) || response.events_json.some(value => typeof value !== 'string')) {
|
|
84
|
+
fail('EXECUTOR_AUDIT_INVALID', 502, 'Executor returned an invalid audit page.');
|
|
85
|
+
}
|
|
86
|
+
let items;
|
|
87
|
+
try {
|
|
88
|
+
items = response.events_json.map((value) => jsonValue(JSON.parse(value)));
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
fail('EXECUTOR_AUDIT_INVALID', 502, 'Executor returned invalid audit events.');
|
|
92
|
+
}
|
|
93
|
+
return { items, next_cursor: typeof response.next_cursor === 'string' && response.next_cursor !== '' ? response.next_cursor : null,
|
|
94
|
+
coverage: { model_transport: 'not_queried', executor_access: 'executor_observed_requesting_declared_client_only',
|
|
95
|
+
exports: 'not_recorded', employee_identity: 'not_authenticated', completeness: 'partial', incomplete_tail: response.incomplete_tail === true },
|
|
96
|
+
integrity: string(response.integrity), ordering: string(response.ordering), scope: string(response.scope) };
|
|
97
|
+
}
|
|
67
98
|
browse(path, signal) {
|
|
68
99
|
return this.call('listAllowedParent', { path }, 30000, signal);
|
|
69
100
|
}
|
|
@@ -73,8 +104,25 @@ export class ExecutorClient {
|
|
|
73
104
|
open(workspacePaths, path, signal) {
|
|
74
105
|
return this.call('openInFileManager', { workspace_paths: workspacePaths, path, full_access: false }, 30000, signal);
|
|
75
106
|
}
|
|
76
|
-
validatePaths(paths, signal, createIfMissing = false) {
|
|
77
|
-
|
|
107
|
+
async validatePaths(paths, signal, createIfMissing = false) {
|
|
108
|
+
const response = await this.call('validateWorkspacePaths', { workspace_paths: paths, create_if_missing: createIfMissing }, 30000, signal);
|
|
109
|
+
const resolved = object(response).resolved_paths;
|
|
110
|
+
if (Array.isArray(resolved))
|
|
111
|
+
await this.verifySharedPaths(resolved.filter((path) => typeof path === 'string'), signal);
|
|
112
|
+
return response;
|
|
113
|
+
}
|
|
114
|
+
sharedWorkspace(path) { return this.#sharedPaths.has(path); }
|
|
115
|
+
async verifySharedPaths(paths, signal) {
|
|
116
|
+
if (this.endpoint.sharedFilesystem !== true)
|
|
117
|
+
return;
|
|
118
|
+
for (const root of paths) {
|
|
119
|
+
this.#sharedPaths.delete(root);
|
|
120
|
+
await verifySharedWorkspace(root, async (path, expected) => {
|
|
121
|
+
const response = object(await this.call('readFile', { workspace_paths: [root], path, full_access: false, max_bytes: 1024, max_lines: 2 }, 5000, signal));
|
|
122
|
+
return response.content === expected;
|
|
123
|
+
});
|
|
124
|
+
this.#sharedPaths.add(root);
|
|
125
|
+
}
|
|
78
126
|
}
|
|
79
127
|
// Drops the Executor's file history for Calls that Core has physically deleted, so revertable
|
|
80
128
|
// state does not outlive the Call it belongs to. Called after the purge transaction commits --
|
|
@@ -90,24 +138,50 @@ export class ExecutorClient {
|
|
|
90
138
|
}
|
|
91
139
|
async execute(invocation) {
|
|
92
140
|
const input = object(invocation.arguments);
|
|
141
|
+
const audit = new grpc.Metadata();
|
|
142
|
+
audit.set('x-lens-call-id', invocation.callId);
|
|
143
|
+
audit.set('x-lens-project-id', invocation.projectId);
|
|
144
|
+
audit.set('x-lens-workspace-id', invocation.workspaceId);
|
|
145
|
+
if (invocation.agentId !== null)
|
|
146
|
+
audit.set('x-lens-agent-id', invocation.agentId);
|
|
147
|
+
if (invocation.taskId !== undefined)
|
|
148
|
+
audit.set('x-lens-task-id', invocation.taskId);
|
|
149
|
+
const call = (method, request, timeout, signal) => this.call(method, request, timeout, signal, audit);
|
|
93
150
|
const common = { workspace_paths: invocation.workspacePaths, full_access: invocation.fullAccess === true };
|
|
94
151
|
const worker = this.config.current.config.worker;
|
|
95
|
-
//
|
|
152
|
+
// edit_file/patch_file carry the Workspace and Call identity so the Executor can record the
|
|
96
153
|
// change in its own file history for a later revert_call. Core never sees the file bytes or
|
|
97
154
|
// their object ids -- see docs/architecture/harness-level3/09-file-snapshot-storage-decision.md.
|
|
98
155
|
const change = { workspace_id: invocation.workspaceId, call_id: invocation.callId };
|
|
99
156
|
if (invocation.capabilityId === 'read_file')
|
|
100
|
-
return result(await
|
|
101
|
-
|
|
102
|
-
|
|
157
|
+
return result(await call('readFile', {
|
|
158
|
+
...common, path: string(input.path), start_line: optionalNumber(input.start_line) ?? 1,
|
|
159
|
+
max_lines: optionalNumber(input.max_lines) ?? 2000, max_bytes: optionalNumber(input.max_bytes) ?? 65536,
|
|
160
|
+
offset: optionalNumber(input.offset) ?? 0
|
|
161
|
+
}, 30000, invocation.signal));
|
|
162
|
+
if (invocation.capabilityId === 'view_image') {
|
|
163
|
+
const image = object(await call('viewImage', { ...common, path: string(input.path) }, 30000, invocation.signal));
|
|
164
|
+
const bytes = Buffer.from(string(image.data), 'base64');
|
|
165
|
+
return { capture: 'complete', content: { path: string(input.path), media_type: string(image.media_type), size_bytes: bytes.length, ...(image.evidence == null ? {} : { evidence: jsonValue(image.evidence) }) },
|
|
166
|
+
artifacts: [{ name: string(input.path).split(/[\\/]/).pop() ?? 'image', mediaType: string(image.media_type), bytes }] };
|
|
103
167
|
}
|
|
104
168
|
if (invocation.capabilityId === 'edit_file') {
|
|
105
|
-
|
|
169
|
+
const edits = input.edits === undefined ? [] : editEntries(input.edits);
|
|
170
|
+
const content = input.content === undefined ? undefined : bounded(text(input.content), 'content');
|
|
171
|
+
const size = edits.reduce((bytes, edit) => bytes + Buffer.byteLength(edit.old_string) + Buffer.byteLength(edit.new_string), 0);
|
|
172
|
+
if (size > contentLimitBytes)
|
|
173
|
+
fail('FILE_TOO_LARGE', 413, 'Total edit input exceeds 8 MiB.');
|
|
174
|
+
return this.#fileChange('editFile', { ...common, ...change, path: string(input.path), operation: string(input.operation),
|
|
175
|
+
...(content === undefined ? {} : { content }), edits }, invocation.signal, audit);
|
|
106
176
|
}
|
|
177
|
+
if (invocation.capabilityId === 'patch_file')
|
|
178
|
+
return this.#fileChange('patchFile', {
|
|
179
|
+
...common, ...change, path: string(input.path), patch: bounded(string(input.patch), 'patch')
|
|
180
|
+
}, invocation.signal, audit);
|
|
107
181
|
if (invocation.capabilityId === 'glob')
|
|
108
|
-
return result(await
|
|
182
|
+
return result(await call('glob', { ...common, pattern: string(input.pattern), path: optionalString(input.path) ?? '.', include_hidden: input.include_hidden === true, max_results: optionalNumber(input.max_results) ?? 1000 }, 30000, invocation.signal));
|
|
109
183
|
if (invocation.capabilityId === 'grep')
|
|
110
|
-
return result(await
|
|
184
|
+
return result(await call('grep', {
|
|
111
185
|
...common,
|
|
112
186
|
pattern: string(input.pattern),
|
|
113
187
|
path: optionalString(input.path) ?? '.',
|
|
@@ -120,30 +194,57 @@ export class ExecutorClient {
|
|
|
120
194
|
}, 30000, invocation.signal));
|
|
121
195
|
if (invocation.capabilityId === 'terminal_command_run') {
|
|
122
196
|
const timeout = optionalNumber(input.timeout_ms) ?? worker.timeoutMs;
|
|
123
|
-
return processResult(await
|
|
197
|
+
return processResult(await call('runCommand', { ...common, command: string(input.command), args: strings(input.args), cwd: optionalString(input.cwd) ?? '.', env: stringRecord(input.env), timeout_ms: timeout, output_limit_bytes: optionalNumber(input.output_limit_bytes) ?? worker.outputLimitBytes }, timeout + 5000, invocation.signal));
|
|
124
198
|
}
|
|
125
|
-
if (invocation.capabilityId === 'git_run') {
|
|
126
|
-
const timeout = optionalNumber(input.timeout_ms) ?? worker.timeoutMs;
|
|
127
|
-
return processResult(await this.call('gitRun', { ...common, args: strings(input.args), cwd: optionalString(input.cwd) ?? '.', timeout_ms: timeout, output_limit_bytes: optionalNumber(input.output_limit_bytes) ?? worker.outputLimitBytes }, timeout + 5000, invocation.signal));
|
|
128
|
-
}
|
|
129
|
-
if (invocation.capabilityId === 'internet_search')
|
|
130
|
-
return result(await this.call('internetSearch', { query: string(input.query), max_results: optionalNumber(input.max_results) ?? 5 }, 30000, invocation.signal));
|
|
131
|
-
if (invocation.capabilityId === 'open_in_file_manager')
|
|
132
|
-
return result(await this.call('openInFileManager', { ...common, path: optionalString(input.path) ?? '.' }, 30000, invocation.signal));
|
|
133
199
|
fail('CAPABILITY_NOT_FOUND', 404, 'The Executor Capability does not exist.');
|
|
134
200
|
}
|
|
135
|
-
|
|
201
|
+
async #fileChange(method, request, signal, audit) {
|
|
202
|
+
try {
|
|
203
|
+
return { content: { structured_content: await this.call(method, request, 30000, signal, audit) }, capture: 'complete' };
|
|
204
|
+
}
|
|
205
|
+
catch (error) {
|
|
206
|
+
if (!(error instanceof ExecutorCallError))
|
|
207
|
+
throw error;
|
|
208
|
+
let details;
|
|
209
|
+
try {
|
|
210
|
+
details = JSON.parse(error.message);
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
throw error;
|
|
214
|
+
}
|
|
215
|
+
if (typeof details !== 'object' || details === null || !('code' in details) || typeof details.code !== 'string'
|
|
216
|
+
|| !/^(?:FILE|EDIT|PATCH)_[A-Z_]+$/u.test(details.code))
|
|
217
|
+
throw error;
|
|
218
|
+
return { outcome: 'failed', errorCode: details.code, capture: 'complete', content: { error: jsonValue(details) } };
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
call(method, request, timeoutMs, signal, audit) {
|
|
222
|
+
const metadata = this.#metadata.clone();
|
|
223
|
+
if (audit !== undefined)
|
|
224
|
+
metadata.merge(audit);
|
|
136
225
|
const invoke = this.#client[method];
|
|
137
226
|
if (invoke === undefined)
|
|
138
227
|
fail('EXECUTOR_PROTOCOL_ERROR', 503, `Executor RPC ${method} is unavailable.`);
|
|
139
228
|
return new Promise((resolveResult, reject) => {
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
229
|
+
signal?.throwIfAborted();
|
|
230
|
+
let call;
|
|
231
|
+
const abort = () => { call?.cancel(); };
|
|
232
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
233
|
+
try {
|
|
234
|
+
call = invoke.call(this.#client, request, metadata, { deadline: Date.now() + timeoutMs }, (error, response) => {
|
|
235
|
+
signal?.removeEventListener('abort', abort);
|
|
236
|
+
if (error !== null)
|
|
237
|
+
reject(new ExecutorCallError(error.details || error.message, error.code));
|
|
238
|
+
else
|
|
239
|
+
resolveResult(jsonValue(response));
|
|
240
|
+
});
|
|
241
|
+
if (signal?.aborted === true)
|
|
242
|
+
call.cancel();
|
|
243
|
+
}
|
|
244
|
+
catch (error) {
|
|
245
|
+
signal?.removeEventListener('abort', abort);
|
|
246
|
+
throw error;
|
|
247
|
+
}
|
|
147
248
|
});
|
|
148
249
|
}
|
|
149
250
|
}
|
|
@@ -152,56 +253,80 @@ export function executorCapabilities(adapter) {
|
|
|
152
253
|
definition,
|
|
153
254
|
adapter,
|
|
154
255
|
manifest: capabilityManifest(definition),
|
|
155
|
-
claims: (
|
|
256
|
+
claims: () => claims(definition.id)
|
|
156
257
|
}));
|
|
157
258
|
}
|
|
158
259
|
const stringSchema = { type: 'string', minLength: 1 };
|
|
159
260
|
const stringArray = { type: 'array', items: { type: 'string' } };
|
|
160
261
|
const environmentSchema = { type: 'object', additionalProperties: { type: 'string' } };
|
|
161
262
|
const positive = { type: 'integer', minimum: 1 };
|
|
162
|
-
const
|
|
263
|
+
const fileChangeSchema = {
|
|
264
|
+
type: 'object', additionalProperties: false,
|
|
265
|
+
properties: { path: stringSchema, changed: { type: 'boolean' }, units_applied: { type: 'integer', minimum: 0 }, bytes_written: { type: 'integer', minimum: 0 } },
|
|
266
|
+
required: ['path', 'changed', 'units_applied', 'bytes_written']
|
|
267
|
+
};
|
|
268
|
+
const outputLimits = { edit_file: 262144, patch_file: 262144, grep: 2097152 };
|
|
163
269
|
const defaultAssignments = {
|
|
164
|
-
read_file: 'all_new_agents',
|
|
270
|
+
read_file: 'all_new_agents', view_image: 'all_new_agents', edit_file: 'all_new_agents', patch_file: 'all_new_agents', glob: 'all_new_agents', grep: 'all_new_agents'
|
|
165
271
|
};
|
|
166
272
|
/**
|
|
167
273
|
* The read-only file loop, loaded for every new Agent rather than discovered.
|
|
168
274
|
*
|
|
169
275
|
* Every Executor Capability used to be `deferred`, so a new Agent's loaded set held no way to see the
|
|
170
276
|
* Workspace at all. Deferred loading is meant to keep rarely-used tool schemas out of the prompt, and
|
|
171
|
-
* for `
|
|
277
|
+
* for `terminal_command_run` that is right -- but an Agent cannot decide whether it needs to
|
|
172
278
|
* look at the files before it has looked at the files. Asked "does a PowerShell script run during the
|
|
173
279
|
* VM build", one searched the catalogue for a VM-build capability, found none, and asked the user to
|
|
174
280
|
* describe their setup -- while the answer sat in a Packer template two `glob` calls away. The failure
|
|
175
281
|
* is structural, not a bad guess: discovery only works when the Agent knows what to look for, and the
|
|
176
282
|
* first question about an unfamiliar Workspace is exactly when it does not.
|
|
177
283
|
*
|
|
178
|
-
*
|
|
284
|
+
* Four schemas is the whole cost. Writing, running commands and reaching the network stay deferred:
|
|
179
285
|
* those are decisions an Agent makes *after* reading, when it does know what it is looking for.
|
|
180
286
|
*/
|
|
181
|
-
const eagerCapabilities = new Set(['read_file', 'glob', 'grep']);
|
|
287
|
+
const eagerCapabilities = new Set(['read_file', 'view_image', 'glob', 'grep']);
|
|
182
288
|
const definitions = [
|
|
183
|
-
definition('read_file', 'Read file', 'Read UTF-8 file
|
|
184
|
-
|
|
185
|
-
|
|
289
|
+
definition('read_file', 'Read file or directory', 'Read a UTF-8 text file or list a directory. Directory names are sorted, include hidden entries, and append / to subdirectories. Defaults to the first 2000 lines or entries, or 64 KiB. Use start_line (1-based), or pass next_offset as UTF-8 byte offset to continue. Do not combine a nonzero offset with start_line > 1. File content preserves line endings. kind identifies file/directory; eof indicates completion. Binary files are rejected.', 'read_only', {
|
|
290
|
+
path: stringSchema, start_line: { ...positive, maximum: Number.MAX_SAFE_INTEGER },
|
|
291
|
+
max_lines: { ...positive, maximum: 10000 }, max_bytes: { type: 'integer', minimum: 4, maximum: 262144 },
|
|
292
|
+
offset: { type: 'integer', minimum: 0, maximum: Number.MAX_SAFE_INTEGER }
|
|
293
|
+
}, ['path']),
|
|
294
|
+
definition('view_image', 'View image', 'View a PNG, JPEG, GIF or WebP image inside the Workspace. Returns image content to the model. Maximum file size: 5 MiB.', 'read_only', { path: stringSchema }, ['path']),
|
|
295
|
+
definition('edit_file', 'Edit file', 'Create a UTF-8 file, rewrite an existing file, or apply ordered literal replacements. create requires a missing target; edit/rewrite require an existing regular UTF-8 file. For create/rewrite provide content and omit edits; for edit provide edits and omit content. Empty content is allowed. Each old_string must match exactly once unless replace_all is true. All edits are validated before one atomic commit. At most 128 edits and 8 MiB of input/file content. Changes are recorded for revert_call.', 'workspace_write', {
|
|
296
|
+
path: stringSchema, operation: { type: 'string', enum: ['create', 'edit', 'rewrite'] }, content: { type: 'string' },
|
|
297
|
+
edits: { type: 'array', minItems: 1, maxItems: 128, items: { type: 'object', additionalProperties: false,
|
|
298
|
+
properties: { old_string: stringSchema, new_string: { type: 'string' }, replace_all: { type: 'boolean', default: false } }, required: ['old_string', 'new_string'] } }
|
|
299
|
+
}, ['path', 'operation'], fileChangeSchema),
|
|
300
|
+
definition('patch_file', 'Patch file', 'Apply contextual line-based update hunks to one existing UTF-8 file. Begin each hunk with @@ or @@ followed by an exact unique line anchor. Prefix unchanged lines with a space, additions with +, removals with -. Supply hunks only: path is separate. All hunks refer to the original file, must match uniquely, and cannot overlap. Optional *** End of File requires matching at the end. Pure additions require an empty file and one hunk. Preserve exact whitespace and unchanged line endings. At most 128 hunks and 8 MiB input/file content. Commit once and record for revert_call.', 'workspace_write', { path: stringSchema, patch: stringSchema }, ['path', 'patch'], fileChangeSchema),
|
|
186
301
|
definition('glob', 'Glob files', 'Find Workspace files by wildcard pattern.', 'read_only', { pattern: stringSchema, path: stringSchema, include_hidden: { type: 'boolean' }, max_results: { ...positive, maximum: 10000 } }, ['pattern']),
|
|
187
|
-
definition('grep', 'Search files', 'Find text or regular expression matches inside Workspace files.', 'read_only', { pattern: stringSchema, path: stringSchema, glob: stringSchema, literal: { type: 'boolean' }, case_sensitive: { type: 'boolean' }, include_hidden: { type: 'boolean' }, max_results: { ...positive, maximum: 10000 }, max_bytes_per_file: {
|
|
188
|
-
definition('terminal_command_run', 'Run command', 'Run a command without a shell inside the Workspace.', 'full', { command: stringSchema, args: stringArray, cwd: stringSchema, env: environmentSchema, timeout_ms: positive, output_limit_bytes: positive }, ['command'])
|
|
189
|
-
definition('git_run', 'Run Git', 'Run Git arguments inside the Workspace.', 'full', { args: stringArray, cwd: stringSchema, timeout_ms: positive, output_limit_bytes: positive }, ['args']),
|
|
190
|
-
definition('internet_search', 'Search internet', 'Search public web pages.', 'external', { query: stringSchema, max_results: { ...positive, maximum: 20 } }, ['query']),
|
|
191
|
-
definition('open_in_file_manager', 'Open in file manager', 'Open a Workspace path in the operating system file manager.', 'external', { path: stringSchema }, [])
|
|
302
|
+
definition('grep', 'Search files', 'Find text or regular expression matches inside Workspace files. Results include 1-based line numbers. truncated means additional matches exist or a file exceeded max_bytes_per_file; narrow path/glob or raise the limits to inspect more.', 'read_only', { pattern: stringSchema, path: stringSchema, glob: stringSchema, literal: { type: 'boolean' }, case_sensitive: { type: 'boolean' }, include_hidden: { type: 'boolean' }, max_results: { ...positive, maximum: 10000 }, max_bytes_per_file: { type: 'integer', minimum: 4, maximum: 10485760 } }, ['pattern']),
|
|
303
|
+
definition('terminal_command_run', 'Run command', 'Run a command without a shell inside the Workspace.', 'full', { command: stringSchema, args: stringArray, cwd: stringSchema, env: environmentSchema, timeout_ms: positive, output_limit_bytes: positive }, ['command'])
|
|
192
304
|
];
|
|
193
|
-
function claims(capabilityId
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
return [{ resource: pathResource(path, paths), mode: ['write_file', 'edit_file', 'terminal_command_run', 'git_run'].includes(capabilityId) ? 'write' : 'read' }];
|
|
305
|
+
function claims(capabilityId) {
|
|
306
|
+
// Core cannot canonicalize a remote filesystem. Cover all aliases and command
|
|
307
|
+
// side effects in this scheduler's execution domain; concurrent reads remain safe.
|
|
308
|
+
return [{ resource: '*', mode: ['edit_file', 'patch_file', 'terminal_command_run'].includes(capabilityId) ? 'write' : 'read' }];
|
|
198
309
|
}
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
310
|
+
export async function verifySharedWorkspace(root, read) {
|
|
311
|
+
let directory;
|
|
312
|
+
try {
|
|
313
|
+
const expected = randomBytes(32).toString('hex');
|
|
314
|
+
let path;
|
|
315
|
+
try {
|
|
316
|
+
directory = await mkdtemp(join(root, '.lens-shared-'));
|
|
317
|
+
path = join(directory, 'probe');
|
|
318
|
+
await writeFile(path, expected, { mode: 0o600 });
|
|
319
|
+
}
|
|
320
|
+
catch {
|
|
321
|
+
fail('SHARED_WORKSPACE_REQUIRED', 409, 'Core could not create a shared Workspace probe.');
|
|
322
|
+
}
|
|
323
|
+
if (!await read(path, expected))
|
|
324
|
+
fail('SHARED_WORKSPACE_REQUIRED', 409, 'Core and Executor do not share this Workspace.');
|
|
325
|
+
}
|
|
326
|
+
finally {
|
|
327
|
+
if (directory !== undefined)
|
|
328
|
+
await rm(directory, { recursive: true, force: true });
|
|
329
|
+
}
|
|
205
330
|
}
|
|
206
331
|
async function channelCredentials(root, config) {
|
|
207
332
|
if (config.tls?.enabled !== true)
|
|
@@ -228,11 +353,20 @@ async function loadIdentity(path) {
|
|
|
228
353
|
await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
|
|
229
354
|
return value;
|
|
230
355
|
}
|
|
231
|
-
function definition(id, title, description, effect, properties, required) {
|
|
232
|
-
return { id, title, description, effect, adapterId: 'executor', loadMode: eagerCapabilities.has(id) ? 'eager' : 'deferred', outputLimitBytes: outputLimits[id] ?? 1048576, defaultAssignment: defaultAssignments[id] ?? 'configured', inputSchema: { type: 'object', additionalProperties: false, properties, required } };
|
|
356
|
+
function definition(id, title, description, effect, properties, required, outputSchema) {
|
|
357
|
+
return { id, title, description, effect, adapterId: 'executor', loadMode: eagerCapabilities.has(id) ? 'eager' : 'deferred', outputLimitBytes: outputLimits[id] ?? 1048576, defaultAssignment: defaultAssignments[id] ?? 'configured', inputSchema: { type: 'object', additionalProperties: false, properties, required }, ...(outputSchema === undefined ? {} : { outputSchema }) };
|
|
358
|
+
}
|
|
359
|
+
function editEntries(value) {
|
|
360
|
+
if (!Array.isArray(value))
|
|
361
|
+
fail('INVALID_INPUT', 400, 'edits must be an array.');
|
|
362
|
+
return value.map((entry) => {
|
|
363
|
+
const edit = object(jsonValue(entry));
|
|
364
|
+
return { old_string: string(edit.old_string), new_string: text(edit.new_string), replace_all: edit.replace_all === true };
|
|
365
|
+
});
|
|
233
366
|
}
|
|
234
367
|
function result(content) {
|
|
235
|
-
|
|
368
|
+
const value = object(content);
|
|
369
|
+
return { content, capture: value.truncated === true ? 'partial' : value.truncated === false ? 'complete' : 'unknown' };
|
|
236
370
|
}
|
|
237
371
|
function bounded(value, field) {
|
|
238
372
|
const bytes = Buffer.byteLength(value, 'utf8');
|
|
@@ -247,6 +381,7 @@ function processResult(content) {
|
|
|
247
381
|
const failed = exitCode !== 0 || signal !== undefined;
|
|
248
382
|
return {
|
|
249
383
|
content,
|
|
384
|
+
capture: value.stdout_truncated === true || value.stderr_truncated === true ? 'partial' : value.stdout_truncated === false && value.stderr_truncated === false ? 'complete' : 'unknown',
|
|
250
385
|
...(failed ? { outcome: 'failed', errorCode: signal === 'TIMEOUT' ? 'WORKER_TIMEOUT' : signal === 'CANCELLED' ? 'WORKER_CANCELLED' : 'WORKER_EXIT_NONZERO' } : {})
|
|
251
386
|
};
|
|
252
387
|
}
|
package/dist/adapters/lsp.d.ts
CHANGED
|
@@ -4,7 +4,8 @@ import type { RegisteredCapability } from '../runtime/capabilities.js';
|
|
|
4
4
|
export declare class LspPool {
|
|
5
5
|
#private;
|
|
6
6
|
readonly servers: NonNullable<HarnessConfig['lspServers']>;
|
|
7
|
-
|
|
7
|
+
readonly verifyWorkspace: (paths: readonly string[]) => Promise<void>;
|
|
8
|
+
constructor(servers: NonNullable<HarnessConfig['lspServers']>, verifyWorkspace?: (paths: readonly string[]) => Promise<void>);
|
|
8
9
|
request(serverId: string, workspacePaths: readonly string[], sessionId: string, method: string, params: Json, signal: AbortSignal): Promise<Json>;
|
|
9
10
|
projectPath(serverId: string, workspacePaths: readonly string[]): string;
|
|
10
11
|
close(): Promise<void>;
|
package/dist/adapters/lsp.js
CHANGED
|
@@ -6,24 +6,60 @@ import { LspProcess } from '../execution/lsp-process.js';
|
|
|
6
6
|
import { capabilityManifest } from '../runtime/manifests.js';
|
|
7
7
|
export class LspPool {
|
|
8
8
|
servers;
|
|
9
|
+
verifyWorkspace;
|
|
9
10
|
#clients = new Map();
|
|
10
|
-
|
|
11
|
+
#closed = false;
|
|
12
|
+
constructor(servers, verifyWorkspace = async () => { }) {
|
|
11
13
|
this.servers = servers;
|
|
14
|
+
this.verifyWorkspace = verifyWorkspace;
|
|
12
15
|
}
|
|
13
16
|
async request(serverId, workspacePaths, sessionId, method, params, signal) {
|
|
17
|
+
signal.throwIfAborted();
|
|
18
|
+
if (this.#closed)
|
|
19
|
+
fail('LSP_SERVER_UNAVAILABLE', 503, 'The LSP pool is closed.');
|
|
20
|
+
await this.verifyWorkspace(workspacePaths);
|
|
21
|
+
signal.throwIfAborted();
|
|
14
22
|
const projectPath = this.projectPath(serverId, workspacePaths);
|
|
15
23
|
assertFileUris(workspacePaths, params);
|
|
16
|
-
const
|
|
24
|
+
const config = this.servers[serverId];
|
|
25
|
+
if (config === undefined)
|
|
26
|
+
fail('LSP_SERVER_UNAVAILABLE', 404, 'The LSP server is not configured.');
|
|
27
|
+
const sessionKey = key(serverId, projectPath, sessionId);
|
|
28
|
+
const restarted = this.#clients.get(sessionKey)?.process.closed ?? true;
|
|
29
|
+
const client = this.#client(serverId, projectPath, sessionKey);
|
|
30
|
+
client.active++;
|
|
31
|
+
if (client.timer !== undefined)
|
|
32
|
+
clearTimeout(client.timer);
|
|
33
|
+
const deadline = new AbortController();
|
|
34
|
+
const timeout = setTimeout(() => deadline.abort(Object.assign(new Error('The LSP request timed out.'), { code: 'LSP_TIMEOUT' })), config.requestTimeoutMs ?? 30000);
|
|
35
|
+
const combined = AbortSignal.any([signal, deadline.signal, client.controller.signal]);
|
|
17
36
|
try {
|
|
37
|
+
const process = await abortable(client.promise, combined);
|
|
38
|
+
let response = null;
|
|
18
39
|
if (isNotification(method))
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
40
|
+
await process.notify(method, params, false, combined);
|
|
41
|
+
else
|
|
42
|
+
response = jsonValue(await process.request(method, params, combined));
|
|
43
|
+
return { method, response, notifications: process.drainNotifications().map(jsonValue), restarted };
|
|
22
44
|
}
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
45
|
+
finally {
|
|
46
|
+
clearTimeout(timeout);
|
|
47
|
+
client.active--;
|
|
48
|
+
if (!client.ready && client.active === 0) {
|
|
49
|
+
client.controller.abort(new Error('No callers remain for LSP initialization.'));
|
|
50
|
+
await client.process.close();
|
|
51
|
+
if (this.#clients.get(sessionKey) === client)
|
|
52
|
+
this.#clients.delete(sessionKey);
|
|
53
|
+
}
|
|
54
|
+
else if (client.active === 0 && this.#clients.get(sessionKey) === client) {
|
|
55
|
+
client.timer = setTimeout(() => {
|
|
56
|
+
if (client.active === 0 && this.#clients.get(sessionKey) === client) {
|
|
57
|
+
this.#clients.delete(sessionKey);
|
|
58
|
+
void client.process.close();
|
|
59
|
+
}
|
|
60
|
+
}, config.idleTimeoutMs ?? 300000);
|
|
61
|
+
client.timer.unref();
|
|
62
|
+
}
|
|
27
63
|
}
|
|
28
64
|
}
|
|
29
65
|
projectPath(serverId, workspacePaths) {
|
|
@@ -37,44 +73,65 @@ export class LspPool {
|
|
|
37
73
|
return project;
|
|
38
74
|
}
|
|
39
75
|
async close() {
|
|
40
|
-
|
|
76
|
+
this.#closed = true;
|
|
77
|
+
const clients = [...this.#clients.values()];
|
|
41
78
|
this.#clients.clear();
|
|
79
|
+
for (const client of clients) {
|
|
80
|
+
if (client.timer !== undefined)
|
|
81
|
+
clearTimeout(client.timer);
|
|
82
|
+
client.controller.abort(new Error('The LSP pool closed.'));
|
|
83
|
+
}
|
|
84
|
+
await Promise.allSettled(clients.map(client => client.process.close()));
|
|
42
85
|
}
|
|
43
|
-
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
86
|
+
#client(serverId, workspacePath, sessionKey) {
|
|
87
|
+
for (const [key, client] of this.#clients) {
|
|
88
|
+
if (!client.process.closed)
|
|
89
|
+
continue;
|
|
90
|
+
if (client.timer !== undefined)
|
|
91
|
+
clearTimeout(client.timer);
|
|
92
|
+
this.#clients.delete(key);
|
|
93
|
+
void client.process.close();
|
|
51
94
|
}
|
|
95
|
+
const existing = this.#clients.get(sessionKey);
|
|
96
|
+
if (existing !== undefined)
|
|
97
|
+
return existing;
|
|
52
98
|
const config = this.servers[serverId];
|
|
53
99
|
if (config === undefined)
|
|
54
|
-
fail('LSP_SERVER_UNAVAILABLE', 404,
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
}
|
|
100
|
+
fail('LSP_SERVER_UNAVAILABLE', 404, 'The LSP server is not configured.');
|
|
101
|
+
if ([...this.#clients.values()].filter(client => client.serverId === serverId).length >= (config.maxSessions ?? 16))
|
|
102
|
+
fail('LSP_SESSION_LIMIT', 429, 'The LSP session limit has been reached.');
|
|
103
|
+
const process = new LspProcess(config.command, config.args ?? [], workspacePath, config.maxFrameBytes ?? 4194304, config.maxBufferBytes ?? 8388608, config.requestTimeoutMs ?? 30000);
|
|
104
|
+
const client = { serverId, process, controller: new AbortController(), promise: Promise.resolve(process), active: 0, ready: false, timer: undefined };
|
|
105
|
+
this.#clients.set(sessionKey, client);
|
|
106
|
+
client.promise = (async () => {
|
|
107
|
+
try {
|
|
108
|
+
await process.request('initialize', { processId: null, rootUri: pathToFileURL(workspacePath).href, capabilities: {}, initializationOptions: config.initializationOptions ?? null }, client.controller.signal);
|
|
109
|
+
client.controller.signal.throwIfAborted();
|
|
110
|
+
process.markInitialized();
|
|
111
|
+
await process.notify('initialized', {});
|
|
112
|
+
client.ready = true;
|
|
113
|
+
return process;
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
await process.close();
|
|
117
|
+
if (this.#clients.get(sessionKey) === client)
|
|
118
|
+
this.#clients.delete(sessionKey);
|
|
119
|
+
throw error;
|
|
120
|
+
}
|
|
121
|
+
})();
|
|
122
|
+
void client.promise.catch(() => undefined);
|
|
123
|
+
return client;
|
|
76
124
|
}
|
|
77
125
|
}
|
|
126
|
+
function abortable(promise, signal) {
|
|
127
|
+
if (signal.aborted)
|
|
128
|
+
return Promise.reject(signal.reason instanceof Error ? signal.reason : new Error('LSP request cancelled.'));
|
|
129
|
+
return new Promise((resolve, reject) => {
|
|
130
|
+
const abort = () => { signal.removeEventListener('abort', abort); reject(signal.reason instanceof Error ? signal.reason : new Error('LSP request cancelled.')); };
|
|
131
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
132
|
+
void promise.then(value => { signal.removeEventListener('abort', abort); resolve(value); }, (error) => { signal.removeEventListener('abort', abort); reject(error instanceof Error ? error : new Error(String(error))); });
|
|
133
|
+
});
|
|
134
|
+
}
|
|
78
135
|
export class LspAdapter {
|
|
79
136
|
pool;
|
|
80
137
|
id = 'lsp';
|
|
@@ -123,7 +180,7 @@ export function lspCapabilities(config, pool) {
|
|
|
123
180
|
adapter,
|
|
124
181
|
manifest: capabilityManifest(definition, ['read_only', 'workspace_write']),
|
|
125
182
|
effect: (input) => writeMethods.has(string(object(input).method)) ? 'workspace_write' : 'read_only',
|
|
126
|
-
claims: (context) => [{ resource:
|
|
183
|
+
claims: (context) => [{ resource: '*', mode: writeMethods.has(string(object(context.arguments).method)) ? 'write' : 'read' }]
|
|
127
184
|
}];
|
|
128
185
|
}
|
|
129
186
|
const allowedMethods = new Set([
|