@lensagent/core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +48 -0
- package/config/lens.example.jsonc +92 -0
- package/config/pair.example.json +3 -0
- package/dist/adapters/executor.d.ts +35 -0
- package/dist/adapters/executor.js +296 -0
- package/dist/adapters/lsp.d.ts +18 -0
- package/dist/adapters/lsp.js +218 -0
- package/dist/adapters/mcp.d.ts +32 -0
- package/dist/adapters/mcp.js +283 -0
- package/dist/adapters/revert.d.ts +12 -0
- package/dist/adapters/revert.js +155 -0
- package/dist/config/runtime.d.ts +55 -0
- package/dist/config/runtime.js +371 -0
- package/dist/config/schema.d.ts +292 -0
- package/dist/config/schema.js +177 -0
- package/dist/contracts/adapter.d.ts +28 -0
- package/dist/contracts/adapter.js +8 -0
- package/dist/contracts/capability.d.ts +41 -0
- package/dist/contracts/capability.js +1 -0
- package/dist/contracts/domain.d.ts +61 -0
- package/dist/contracts/domain.js +1 -0
- package/dist/contracts/index.d.ts +5 -0
- package/dist/contracts/index.js +5 -0
- package/dist/contracts/model.d.ts +77 -0
- package/dist/contracts/model.js +1 -0
- package/dist/contracts/ports.d.ts +14 -0
- package/dist/contracts/ports.js +14 -0
- package/dist/core/access.d.ts +24 -0
- package/dist/core/access.js +205 -0
- package/dist/core/codec.d.ts +33 -0
- package/dist/core/codec.js +105 -0
- package/dist/core/domain.d.ts +13 -0
- package/dist/core/domain.js +24 -0
- package/dist/core/host.d.ts +19 -0
- package/dist/core/host.js +249 -0
- package/dist/core/identity.d.ts +19 -0
- package/dist/core/identity.js +54 -0
- package/dist/entry/bun.d.ts +1 -0
- package/dist/entry/bun.js +2 -0
- package/dist/entry/node.d.ts +2 -0
- package/dist/entry/node.js +3 -0
- package/dist/entry/rebuild.d.ts +1 -0
- package/dist/entry/rebuild.js +47 -0
- package/dist/entry/run.d.ts +1 -0
- package/dist/entry/run.js +43 -0
- package/dist/execution/lsp-process.d.ts +14 -0
- package/dist/execution/lsp-process.js +234 -0
- package/dist/http/server.d.ts +34 -0
- package/dist/http/server.js +1332 -0
- package/dist/launch.d.ts +3 -0
- package/dist/launch.js +4 -0
- package/dist/runtime/agent.d.ts +35 -0
- package/dist/runtime/agent.js +853 -0
- package/dist/runtime/artifacts.d.ts +15 -0
- package/dist/runtime/artifacts.js +72 -0
- package/dist/runtime/cache.d.ts +20 -0
- package/dist/runtime/cache.js +44 -0
- package/dist/runtime/calls.d.ts +21 -0
- package/dist/runtime/calls.js +130 -0
- package/dist/runtime/capabilities.d.ts +58 -0
- package/dist/runtime/capabilities.js +196 -0
- package/dist/runtime/cleanup.d.ts +7 -0
- package/dist/runtime/cleanup.js +33 -0
- package/dist/runtime/context.d.ts +34 -0
- package/dist/runtime/context.js +169 -0
- package/dist/runtime/diagnostics.d.ts +8 -0
- package/dist/runtime/diagnostics.js +71 -0
- package/dist/runtime/events.d.ts +22 -0
- package/dist/runtime/events.js +131 -0
- package/dist/runtime/kernel-capabilities.d.ts +32 -0
- package/dist/runtime/kernel-capabilities.js +200 -0
- package/dist/runtime/manifests.d.ts +14 -0
- package/dist/runtime/manifests.js +117 -0
- package/dist/runtime/model.d.ts +20 -0
- package/dist/runtime/model.js +241 -0
- package/dist/runtime/observability.d.ts +22 -0
- package/dist/runtime/observability.js +104 -0
- package/dist/runtime/policy.d.ts +8 -0
- package/dist/runtime/policy.js +31 -0
- package/dist/runtime/profile.d.ts +8 -0
- package/dist/runtime/profile.js +52 -0
- package/dist/runtime/projects.d.ts +40 -0
- package/dist/runtime/projects.js +115 -0
- package/dist/runtime/results.d.ts +7 -0
- package/dist/runtime/results.js +55 -0
- package/dist/runtime/review.d.ts +9 -0
- package/dist/runtime/review.js +54 -0
- package/dist/runtime/scheduler.d.ts +15 -0
- package/dist/runtime/scheduler.js +85 -0
- package/dist/runtime/skills.d.ts +2 -0
- package/dist/runtime/skills.js +83 -0
- package/dist/runtime/tokenizer.d.ts +8 -0
- package/dist/runtime/tokenizer.js +30 -0
- package/dist/store/database.d.ts +3 -0
- package/dist/store/database.js +49 -0
- package/dist/store/event-log.d.ts +19 -0
- package/dist/store/event-log.js +61 -0
- package/dist/store/schema.d.ts +1 -0
- package/dist/store/schema.js +347 -0
- package/dist/store/store.d.ts +344 -0
- package/dist/store/store.js +2554 -0
- package/package.json +73 -0
- package/proto/executor.proto +314 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 LensAgent
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# lens-core
|
|
2
|
+
|
|
3
|
+
`lens-core` 是 LensAgent 的后端 Core / Harness 仓库。当前实际行为以源码、Schema、配置、脚本、测试和可复核运行结果为准。
|
|
4
|
+
|
|
5
|
+
## 仓库与运行
|
|
6
|
+
|
|
7
|
+
仓库根目录本身就是可安装、构建和运行的 Node.js 项目。文件、终端、Git 和搜索能力由独立仓库 `lens-remote-executor` 提供;本仓库通过 gRPC 与其通信。两侧各自维护一份 `proto/executor.proto`,修改 RPC 契约时需要同步。
|
|
8
|
+
|
|
9
|
+
要求 Node.js 24 或更高版本。
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pnpm install
|
|
13
|
+
pnpm run typecheck
|
|
14
|
+
pnpm run lint
|
|
15
|
+
pnpm run build
|
|
16
|
+
pnpm run secret-scan
|
|
17
|
+
pnpm run dev
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
默认配置模板位于 `config/lens.example.jsonc`。本地运行时复制为 `config/lens.jsonc`。配置与 `config/pair.json` 的相对路径均以进程 `cwd` 为根。
|
|
21
|
+
|
|
22
|
+
已安装包的入口与构建产物入口:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
lens-core
|
|
26
|
+
node dist/entry/node.js
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Executor 需要独立启动;`pnpm run dev` 不会自动启动它。
|
|
30
|
+
|
|
31
|
+
## 文档
|
|
32
|
+
|
|
33
|
+
2026-09-19 之前的架构设计、Context、计划、研究、审查、日期快照及相关生成物已归档到 `waste/2026-09-19/`,不再作为当前规范。
|
|
34
|
+
|
|
35
|
+
当前保留:
|
|
36
|
+
|
|
37
|
+
- [AGENTS.md](./AGENTS.md) — 仓库级工程与 Agent 工作规则。
|
|
38
|
+
- [CLAUDE.md](./CLAUDE.md) — Claude Code 的最小入口。
|
|
39
|
+
- [CONTEXT.md](./CONTEXT.md) — 当前术语、事实边界与工程共享语境。
|
|
40
|
+
- [docs/2026-09-19/current-system-architecture.md](./docs/2026-09-19/current-system-architecture.md) — 基于当前源码与 CodeGraph 重建的系统架构快照。
|
|
41
|
+
- [docs/DOCUMENT-LIFECYCLE.md](./docs/DOCUMENT-LIFECYCLE.md) — 文档治理与归档规则。
|
|
42
|
+
- [waste/manifest.md](./waste/manifest.md) — 历史归档清单。
|
|
43
|
+
|
|
44
|
+
不要根据 `waste/` 中的历史资料推断当前行为;需要确认时直接检查当前代码和可执行证据。
|
|
45
|
+
|
|
46
|
+
## 许可证
|
|
47
|
+
|
|
48
|
+
本项目使用 [MIT License](./LICENSE)。
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
{
|
|
2
|
+
"listen": { "host": "127.0.0.1", "port": 4317 },
|
|
3
|
+
"tls": { "enabled": false, "certFile": null, "keyFile": null },
|
|
4
|
+
"database": { "path": "data/lens.db" },
|
|
5
|
+
"pairPath": "config/pair.json",
|
|
6
|
+
// Upper bound on the ordered shutdown, in milliseconds. Optional; 30000 when omitted. Read when a
|
|
7
|
+
// stop begins, so a change applies to the next stop without a restart. A stage that never settles
|
|
8
|
+
// -- a Worker ignoring cancellation, an LSP child not exiting -- stops holding the process past
|
|
9
|
+
// this point; anything left unconfirmed is resolved by Recovery on the next start.
|
|
10
|
+
"shutdown": { "graceMs": 30000 },
|
|
11
|
+
"executor": { "address": "127.0.0.1:50051" },
|
|
12
|
+
"artifacts": { "directory": "artifacts" },
|
|
13
|
+
"persistence": {
|
|
14
|
+
"eventLog": { "enabled": false, "path": "data/lens.events.jsonl", "fsync": false },
|
|
15
|
+
"contentStore": { "mode": "filesystem" }
|
|
16
|
+
},
|
|
17
|
+
"observability": {
|
|
18
|
+
"logs": { "enabled": true, "path": "runtime/lens.jsonl", "maxFileBytes": 10485760, "maxFiles": 5, "retentionDays": 14 },
|
|
19
|
+
"traces": { "capacity": 2048 }
|
|
20
|
+
},
|
|
21
|
+
"mcpServers": {},
|
|
22
|
+
"lspServers": {},
|
|
23
|
+
"providers": {
|
|
24
|
+
"openai": {
|
|
25
|
+
"adapter": "@ai-sdk/openai",
|
|
26
|
+
"options": { "apiKey": { "env": "OPENAI_API_KEY", "optional": true } },
|
|
27
|
+
"models": { "gpt-5": { "contextWindowTokens": 400000 } }
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"modelHarnessProfiles": {},
|
|
31
|
+
"roles": {
|
|
32
|
+
"agent": {
|
|
33
|
+
"model": "openai/gpt-5",
|
|
34
|
+
"modelReasoningEffort": "high",
|
|
35
|
+
"policyPreset": "Write",
|
|
36
|
+
"capabilities": ["capability_search", "programmatic_tool_run", "system_echo", "read_file", "write_file", "edit_file", "glob", "grep", "terminal_command_run", "git_run", "internet_search", "update_plan", "ask_user", "compact", "subagent_create", "subagent_message", "subagent_broadcast", "subagent_wait"],
|
|
37
|
+
"contextStrategy": "default",
|
|
38
|
+
"providerRouting": { "retryMaxAttempts": 2, "fallback": [] }
|
|
39
|
+
},
|
|
40
|
+
"subagent": {
|
|
41
|
+
"model": "openai/gpt-5",
|
|
42
|
+
"modelReasoningEffort": "medium",
|
|
43
|
+
"policyPreset": "Write",
|
|
44
|
+
"capabilities": ["capability_search", "programmatic_tool_run", "system_echo", "read_file", "write_file", "edit_file", "glob", "grep", "terminal_command_run", "git_run", "internet_search", "update_plan", "ask_user", "subagent_wait"],
|
|
45
|
+
"contextStrategy": "default",
|
|
46
|
+
"providerRouting": { "retryMaxAttempts": 2, "fallback": [] }
|
|
47
|
+
},
|
|
48
|
+
"compactor": {
|
|
49
|
+
"model": "openai/gpt-5",
|
|
50
|
+
"modelReasoningEffort": "medium",
|
|
51
|
+
"policyPreset": null,
|
|
52
|
+
"capabilities": [],
|
|
53
|
+
"contextStrategy": "default",
|
|
54
|
+
"providerRouting": { "retryMaxAttempts": 2, "fallback": [] }
|
|
55
|
+
},
|
|
56
|
+
"auto-review": {
|
|
57
|
+
"model": "openai/gpt-5",
|
|
58
|
+
"modelReasoningEffort": "medium",
|
|
59
|
+
"policyPreset": null,
|
|
60
|
+
"capabilities": [],
|
|
61
|
+
"contextStrategy": null,
|
|
62
|
+
"providerRouting": { "retryMaxAttempts": 1, "fallback": [] }
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
"tokenizer": { "mode": "universal_tel", "version": 1 },
|
|
66
|
+
"policyPresets": {
|
|
67
|
+
"Read": { "capabilities": {
|
|
68
|
+
"capability_search": "read_only", "programmatic_tool_run": "full_access", "system_echo": "read_only", "read_file": "read_only", "glob": "read_only", "grep": "read_only", "git_run": "read_only", "internet_search": "external", "subagent_wait": "read_only"
|
|
69
|
+
} },
|
|
70
|
+
"Write": { "capabilities": {
|
|
71
|
+
"capability_search": "read_only", "programmatic_tool_run": "full_access", "system_echo": "read_only", "read_file": "read_only", "write_file": "workspace_write", "edit_file": "workspace_write", "glob": "read_only", "grep": "read_only", "terminal_command_run": "external", "git_run": "workspace_write", "internet_search": "external", "update_plan": "workspace_write", "update_todo": "workspace_write", "ask_user": "read_only", "compact": "ask", "subagent_create": "workspace_write", "subagent_message": "workspace_write", "subagent_broadcast": "workspace_write", "subagent_wait": "read_only", "slash_command_list": "read_only", "slash_command_run": "ask"
|
|
72
|
+
} }
|
|
73
|
+
},
|
|
74
|
+
"concurrency": { "instanceMax": 8, "perStepMax": 4, "perAdapterMax": 4 },
|
|
75
|
+
"worker": { "timeoutMs": 120000, "outputLimitBytes": 1048576 },
|
|
76
|
+
"contextBudget": { "outputReserveTokens": 8192, "safetyMarginTokens": 2048 },
|
|
77
|
+
"capabilityInput": { "catalogMaxTokens": 4096, "searchResultMaxTokens": 4096 },
|
|
78
|
+
"contextProjection": {
|
|
79
|
+
"toolResultMaxTokens": 8192,
|
|
80
|
+
"snip": { "triggerTokens": 64000, "retainedTokens": 32000 },
|
|
81
|
+
"microcompact": { "triggerTokens": 96000, "projectedTokens": 48000 },
|
|
82
|
+
"contextCollapse": { "triggerTokens": 128000, "projectedTokens": 64000 },
|
|
83
|
+
"stageVersion": "v1"
|
|
84
|
+
},
|
|
85
|
+
"compaction": { "preservedSegmentTokens": 16000, "preservedSegmentBytes": 262144, "summaryMaxTokens": 8192 },
|
|
86
|
+
"autoReview": { "timeoutMs": 30000, "goalMaxBytes": 32768 },
|
|
87
|
+
"executive": { "verification": [] },
|
|
88
|
+
"providerPromptCache": { "enabled": false, "minimumEligiblePrefixTokens": 1024, "maximumBreakpoints": 4 },
|
|
89
|
+
"historyRetention": { "purgeMode": "manual", "purgeArchivedAfterDays": null },
|
|
90
|
+
"snapshot": { "maxRuntimeEntities": 50000, "pageSizeDefault": 100, "pageSizeMax": 500 },
|
|
91
|
+
"sse": { "heartbeatIntervalMs": 15000, "maxBufferedEvents": 1024, "maxBufferedBytes": 1048576 }
|
|
92
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { type CapabilityAdapter, type CapabilityInvocation, type CapabilityOutput, type Json } from '../core/domain.js';
|
|
2
|
+
import type { ConfigRuntime } from '../config/runtime.js';
|
|
3
|
+
import type { RegisteredCapability } from '../runtime/capabilities.js';
|
|
4
|
+
/**
|
|
5
|
+
* Carries the Executor's gRPC status alongside the message. Without it every failure looked the
|
|
6
|
+
* same to callers, so a path the Executor deliberately rejected was reported as the Executor
|
|
7
|
+
* being unavailable -- a 503 marked retryable for a request that can never succeed.
|
|
8
|
+
*/
|
|
9
|
+
export declare class ExecutorCallError extends Error {
|
|
10
|
+
readonly grpcCode: number;
|
|
11
|
+
constructor(message: string, grpcCode: number);
|
|
12
|
+
/** True when the Executor answered and refused, rather than failing to answer. */
|
|
13
|
+
get refused(): boolean;
|
|
14
|
+
}
|
|
15
|
+
type ObjectValue = Readonly<Record<string, unknown>>;
|
|
16
|
+
export declare class ExecutorClient implements CapabilityAdapter {
|
|
17
|
+
#private;
|
|
18
|
+
readonly config: ConfigRuntime;
|
|
19
|
+
readonly id = "executor";
|
|
20
|
+
private constructor();
|
|
21
|
+
static create(root: string, runtime: ConfigRuntime): Promise<ExecutorClient>;
|
|
22
|
+
close(): void;
|
|
23
|
+
health(signal?: AbortSignal): Promise<Json>;
|
|
24
|
+
browse(path: string, signal?: AbortSignal): Promise<Json>;
|
|
25
|
+
chooseFolder(_initialPath?: string, signal?: AbortSignal): Promise<Json>;
|
|
26
|
+
open(workspacePaths: readonly string[], path: string, signal?: AbortSignal): Promise<Json>;
|
|
27
|
+
validatePaths(paths: readonly string[], signal?: AbortSignal, createIfMissing?: boolean): Promise<Json>;
|
|
28
|
+
deleteCallHistory(workspaceId: string, callIds: readonly string[], signal?: AbortSignal): Promise<Json>;
|
|
29
|
+
describeCallHistory(workspaceId: string, callIds: readonly string[], workspacePaths: readonly string[], fullAccess: boolean, signal?: AbortSignal): Promise<Json>;
|
|
30
|
+
execute(invocation: CapabilityInvocation): Promise<CapabilityOutput>;
|
|
31
|
+
call(method: string, request: ObjectValue, timeoutMs: number, signal?: AbortSignal): Promise<Json>;
|
|
32
|
+
}
|
|
33
|
+
export declare function executorCapabilities(adapter: ExecutorClient): readonly RegisteredCapability[];
|
|
34
|
+
export declare function pathResource(path: string, workspacePaths: readonly string[]): string;
|
|
35
|
+
export {};
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { dirname, isAbsolute, resolve } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import * as grpc from '@grpc/grpc-js';
|
|
6
|
+
import * as protoLoader from '@grpc/proto-loader';
|
|
7
|
+
import { fail } from '../core/domain.js';
|
|
8
|
+
import { capabilityManifest } from '../runtime/manifests.js';
|
|
9
|
+
/**
|
|
10
|
+
* Carries the Executor's gRPC status alongside the message. Without it every failure looked the
|
|
11
|
+
* same to callers, so a path the Executor deliberately rejected was reported as the Executor
|
|
12
|
+
* being unavailable -- a 503 marked retryable for a request that can never succeed.
|
|
13
|
+
*/
|
|
14
|
+
export class ExecutorCallError extends Error {
|
|
15
|
+
grpcCode;
|
|
16
|
+
constructor(message, grpcCode) {
|
|
17
|
+
super(message);
|
|
18
|
+
this.grpcCode = grpcCode;
|
|
19
|
+
this.name = 'ExecutorCallError';
|
|
20
|
+
}
|
|
21
|
+
/** True when the Executor answered and refused, rather than failing to answer. */
|
|
22
|
+
get refused() {
|
|
23
|
+
return [grpc.status.INVALID_ARGUMENT, grpc.status.NOT_FOUND, grpc.status.ALREADY_EXISTS,
|
|
24
|
+
grpc.status.PERMISSION_DENIED, grpc.status.FAILED_PRECONDITION, grpc.status.OUT_OF_RANGE].includes(this.grpcCode);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
// core carries its own copy of executor.proto (core/proto/executor.proto) since core and
|
|
28
|
+
// executor are independent, non-shared projects that only communicate over the gRPC port. If
|
|
29
|
+
// the RPC schema changes, mirror the change in executor/proto/executor.proto.
|
|
30
|
+
const executorProtoPath = resolve(dirname(fileURLToPath(import.meta.url)), '../../proto/executor.proto');
|
|
31
|
+
// Set explicitly instead of inheriting @grpc/grpc-js's 4 MiB receive default. Keep in step with the
|
|
32
|
+
// same constant in lens-remote-executor's src/main.ts: the Executor's server-side receive limit is
|
|
33
|
+
// what actually bounds a write_file request, so both sides have to agree.
|
|
34
|
+
const maxMessageBytes = 16 * 1024 * 1024;
|
|
35
|
+
// Rejected in Core with a usable message rather than letting the transport fail with
|
|
36
|
+
// RESOURCE_EXHAUSTED. Below maxMessageBytes so the rest of the request always fits.
|
|
37
|
+
const contentLimitBytes = 8 * 1024 * 1024;
|
|
38
|
+
export class ExecutorClient {
|
|
39
|
+
config;
|
|
40
|
+
id = 'executor';
|
|
41
|
+
#client;
|
|
42
|
+
#metadata = new grpc.Metadata();
|
|
43
|
+
constructor(client, config, identity) {
|
|
44
|
+
this.config = config;
|
|
45
|
+
this.#client = client;
|
|
46
|
+
if (identity !== undefined) {
|
|
47
|
+
this.#metadata.set('x-lens-client-kind', 'native');
|
|
48
|
+
this.#metadata.set('x-lens-client-mac', identity.mac);
|
|
49
|
+
this.#metadata.set('x-lens-hardware-id', identity.hardwareId);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
static async create(root, runtime) {
|
|
53
|
+
const config = runtime.current.config.executor;
|
|
54
|
+
const packageDefinition = protoLoader.loadSync(executorProtoPath, { keepCase: true, longs: Number, defaults: true, oneofs: true });
|
|
55
|
+
const loaded = grpc.loadPackageDefinition(packageDefinition);
|
|
56
|
+
const credentials = await channelCredentials(root, config);
|
|
57
|
+
const clientIdentity = config.identityPath === undefined ? undefined : await loadIdentity(resolve(root, config.identityPath));
|
|
58
|
+
const options = { 'grpc.max_receive_message_length': maxMessageBytes, 'grpc.max_send_message_length': maxMessageBytes };
|
|
59
|
+
return new ExecutorClient(new loaded.lens.executor.v1.Executor(config.address, credentials, options), runtime, clientIdentity);
|
|
60
|
+
}
|
|
61
|
+
close() {
|
|
62
|
+
this.#client.close();
|
|
63
|
+
}
|
|
64
|
+
health(signal) {
|
|
65
|
+
return this.call('health', {}, 5000, signal);
|
|
66
|
+
}
|
|
67
|
+
browse(path, signal) {
|
|
68
|
+
return this.call('listAllowedParent', { path }, 30000, signal);
|
|
69
|
+
}
|
|
70
|
+
chooseFolder(_initialPath, signal) {
|
|
71
|
+
return this.call('chooseWorkspaceFolder', {}, 605000, signal);
|
|
72
|
+
}
|
|
73
|
+
open(workspacePaths, path, signal) {
|
|
74
|
+
return this.call('openInFileManager', { workspace_paths: workspacePaths, path, full_access: false }, 30000, signal);
|
|
75
|
+
}
|
|
76
|
+
validatePaths(paths, signal, createIfMissing = false) {
|
|
77
|
+
return this.call('validateWorkspacePaths', { workspace_paths: paths, create_if_missing: createIfMissing }, 30000, signal);
|
|
78
|
+
}
|
|
79
|
+
// Drops the Executor's file history for Calls that Core has physically deleted, so revertable
|
|
80
|
+
// state does not outlive the Call it belongs to. Called after the purge transaction commits --
|
|
81
|
+
// never from inside a Store transaction.
|
|
82
|
+
deleteCallHistory(workspaceId, callIds, signal) {
|
|
83
|
+
return this.call('deleteCallHistory', { workspace_id: workspaceId, call_ids: callIds }, 120000, signal);
|
|
84
|
+
}
|
|
85
|
+
// What revert_call would do to these Calls right now, without doing it. The answer can be
|
|
86
|
+
// invalidated by any later write, so it is a preview for the user -- not a permission the
|
|
87
|
+
// subsequent revert relies on. The Executor re-simulates when the revert actually runs.
|
|
88
|
+
describeCallHistory(workspaceId, callIds, workspacePaths, fullAccess, signal) {
|
|
89
|
+
return this.call('describeCallHistory', { workspace_paths: workspacePaths, workspace_id: workspaceId, call_ids: callIds, full_access: fullAccess }, 30000, signal);
|
|
90
|
+
}
|
|
91
|
+
async execute(invocation) {
|
|
92
|
+
const input = object(invocation.arguments);
|
|
93
|
+
const common = { workspace_paths: invocation.workspacePaths, full_access: invocation.fullAccess === true };
|
|
94
|
+
const worker = this.config.current.config.worker;
|
|
95
|
+
// write_file/edit_file carry the Workspace and Call identity so the Executor can record the
|
|
96
|
+
// change in its own file history for a later revert_call. Core never sees the file bytes or
|
|
97
|
+
// their object ids -- see docs/architecture/harness-level3/09-file-snapshot-storage-decision.md.
|
|
98
|
+
const change = { workspace_id: invocation.workspaceId, call_id: invocation.callId };
|
|
99
|
+
if (invocation.capabilityId === 'read_file')
|
|
100
|
+
return result(await this.call('readFile', { ...common, path: string(input.path) }, 30000, invocation.signal));
|
|
101
|
+
if (invocation.capabilityId === 'write_file') {
|
|
102
|
+
return result(await this.call('writeFile', { ...common, ...change, path: string(input.path), content: bounded(text(input.content), 'content') }, 30000, invocation.signal));
|
|
103
|
+
}
|
|
104
|
+
if (invocation.capabilityId === 'edit_file') {
|
|
105
|
+
return result(await this.call('editFile', { ...common, ...change, path: string(input.path), old_string: string(input.old_string), new_string: bounded(text(input.new_string), 'new_string'), replace_all: input.replace_all === true }, 30000, invocation.signal));
|
|
106
|
+
}
|
|
107
|
+
if (invocation.capabilityId === 'glob')
|
|
108
|
+
return result(await this.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
|
+
if (invocation.capabilityId === 'grep')
|
|
110
|
+
return result(await this.call('grep', {
|
|
111
|
+
...common,
|
|
112
|
+
pattern: string(input.pattern),
|
|
113
|
+
path: optionalString(input.path) ?? '.',
|
|
114
|
+
glob: optionalString(input.glob) ?? '',
|
|
115
|
+
include_hidden: input.include_hidden === true,
|
|
116
|
+
literal: input.literal === true,
|
|
117
|
+
case_insensitive: input.case_sensitive === false,
|
|
118
|
+
max_results: optionalNumber(input.max_results) ?? 1000,
|
|
119
|
+
max_bytes_per_file: optionalNumber(input.max_bytes_per_file) ?? 1048576
|
|
120
|
+
}, 30000, invocation.signal));
|
|
121
|
+
if (invocation.capabilityId === 'terminal_command_run') {
|
|
122
|
+
const timeout = optionalNumber(input.timeout_ms) ?? worker.timeoutMs;
|
|
123
|
+
return processResult(await this.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
|
+
}
|
|
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
|
+
fail('CAPABILITY_NOT_FOUND', 404, 'The Executor Capability does not exist.');
|
|
134
|
+
}
|
|
135
|
+
call(method, request, timeoutMs, signal) {
|
|
136
|
+
const invoke = this.#client[method];
|
|
137
|
+
if (invoke === undefined)
|
|
138
|
+
fail('EXECUTOR_PROTOCOL_ERROR', 503, `Executor RPC ${method} is unavailable.`);
|
|
139
|
+
return new Promise((resolveResult, reject) => {
|
|
140
|
+
const call = invoke.call(this.#client, request, this.#metadata, { deadline: Date.now() + timeoutMs }, (error, response) => {
|
|
141
|
+
if (error !== null)
|
|
142
|
+
reject(new ExecutorCallError(error.details || error.message, error.code));
|
|
143
|
+
else
|
|
144
|
+
resolveResult(jsonValue(response));
|
|
145
|
+
});
|
|
146
|
+
signal?.addEventListener('abort', () => call.cancel(), { once: true });
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
export function executorCapabilities(adapter) {
|
|
151
|
+
return definitions.map((definition) => ({
|
|
152
|
+
definition,
|
|
153
|
+
adapter,
|
|
154
|
+
manifest: capabilityManifest(definition),
|
|
155
|
+
claims: (context) => claims(definition.id, object(context.arguments), context.workspacePaths)
|
|
156
|
+
}));
|
|
157
|
+
}
|
|
158
|
+
const stringSchema = { type: 'string', minLength: 1 };
|
|
159
|
+
const stringArray = { type: 'array', items: { type: 'string' } };
|
|
160
|
+
const environmentSchema = { type: 'object', additionalProperties: { type: 'string' } };
|
|
161
|
+
const positive = { type: 'integer', minimum: 1 };
|
|
162
|
+
const outputLimits = { edit_file: 262144, internet_search: 262144, grep: 2097152, open_in_file_manager: 4096 };
|
|
163
|
+
const defaultAssignments = {
|
|
164
|
+
read_file: 'all_new_agents', write_file: 'all_new_agents', edit_file: 'all_new_agents', glob: 'all_new_agents', grep: 'all_new_agents', open_in_file_manager: 'none'
|
|
165
|
+
};
|
|
166
|
+
/**
|
|
167
|
+
* The read-only file loop, loaded for every new Agent rather than discovered.
|
|
168
|
+
*
|
|
169
|
+
* Every Executor Capability used to be `deferred`, so a new Agent's loaded set held no way to see the
|
|
170
|
+
* Workspace at all. Deferred loading is meant to keep rarely-used tool schemas out of the prompt, and
|
|
171
|
+
* for `git_run` or `internet_search` that is right -- but an Agent cannot decide whether it needs to
|
|
172
|
+
* look at the files before it has looked at the files. Asked "does a PowerShell script run during the
|
|
173
|
+
* VM build", one searched the catalogue for a VM-build capability, found none, and asked the user to
|
|
174
|
+
* describe their setup -- while the answer sat in a Packer template two `glob` calls away. The failure
|
|
175
|
+
* is structural, not a bad guess: discovery only works when the Agent knows what to look for, and the
|
|
176
|
+
* first question about an unfamiliar Workspace is exactly when it does not.
|
|
177
|
+
*
|
|
178
|
+
* Three schemas is the whole cost. Writing, running commands and reaching the network stay deferred:
|
|
179
|
+
* those are decisions an Agent makes *after* reading, when it does know what it is looking for.
|
|
180
|
+
*/
|
|
181
|
+
const eagerCapabilities = new Set(['read_file', 'glob', 'grep']);
|
|
182
|
+
const definitions = [
|
|
183
|
+
definition('read_file', 'Read file', 'Read UTF-8 file content inside the Workspace.', 'read_only', { path: stringSchema }, ['path']),
|
|
184
|
+
definition('write_file', 'Write file', 'Write UTF-8 file content inside the Workspace.', 'workspace_write', { path: stringSchema, content: { type: 'string' } }, ['path', 'content']),
|
|
185
|
+
definition('edit_file', 'Edit file', 'Replace exact text inside a Workspace file.', 'workspace_write', { path: stringSchema, old_string: stringSchema, new_string: { type: 'string' }, replace_all: { type: 'boolean' } }, ['path', 'old_string', 'new_string']),
|
|
186
|
+
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: { ...positive, maximum: 10485760 } }, ['pattern']),
|
|
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 }, [])
|
|
192
|
+
];
|
|
193
|
+
function claims(capabilityId, input, paths) {
|
|
194
|
+
if (capabilityId === 'internet_search')
|
|
195
|
+
return [{ resource: 'network', mode: 'read' }];
|
|
196
|
+
const path = optionalString(input.path) ?? optionalString(input.cwd) ?? '.';
|
|
197
|
+
return [{ resource: pathResource(path, paths), mode: ['write_file', 'edit_file', 'terminal_command_run', 'git_run'].includes(capabilityId) ? 'write' : 'read' }];
|
|
198
|
+
}
|
|
199
|
+
// The resource name a file Capability claims for a path. Shared so revert_call names the same
|
|
200
|
+
// resource its target write_file/edit_file Calls named -- claims only detect conflicts if both sides
|
|
201
|
+
// spell the resource identically. Note this is a naive resolve, not a realpath: the vocabulary is
|
|
202
|
+
// internally consistent rather than canonical, which is what conflict detection needs.
|
|
203
|
+
export function pathResource(path, workspacePaths) {
|
|
204
|
+
return isAbsolute(path) ? resolve(path) : resolve(workspacePaths[0] ?? '*', path);
|
|
205
|
+
}
|
|
206
|
+
async function channelCredentials(root, config) {
|
|
207
|
+
if (config.tls?.enabled !== true)
|
|
208
|
+
return grpc.credentials.createInsecure();
|
|
209
|
+
const ca = config.tls.caFile === undefined ? undefined : await readFile(resolve(root, config.tls.caFile));
|
|
210
|
+
const cert = config.tls.certFile === undefined ? undefined : await readFile(resolve(root, config.tls.certFile));
|
|
211
|
+
const key = config.tls.keyFile === undefined ? undefined : await readFile(resolve(root, config.tls.keyFile));
|
|
212
|
+
return grpc.credentials.createSsl(ca, key, cert);
|
|
213
|
+
}
|
|
214
|
+
async function loadIdentity(path) {
|
|
215
|
+
try {
|
|
216
|
+
const document = JSON.parse(await readFile(path, 'utf8'));
|
|
217
|
+
if (typeof document.mac === 'string' && typeof document.hardwareId === 'string')
|
|
218
|
+
return { mac: document.mac, hardwareId: document.hardwareId };
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
// The identity file is created below when absent or invalid.
|
|
222
|
+
}
|
|
223
|
+
const value = {
|
|
224
|
+
mac: [...randomBytes(6)].map((byte) => byte.toString(16).padStart(2, '0')).join(':'),
|
|
225
|
+
hardwareId: randomBytes(16).toString('hex')
|
|
226
|
+
};
|
|
227
|
+
await mkdir(dirname(path), { recursive: true });
|
|
228
|
+
await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
|
|
229
|
+
return value;
|
|
230
|
+
}
|
|
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 } };
|
|
233
|
+
}
|
|
234
|
+
function result(content) {
|
|
235
|
+
return { content };
|
|
236
|
+
}
|
|
237
|
+
function bounded(value, field) {
|
|
238
|
+
const bytes = Buffer.byteLength(value, 'utf8');
|
|
239
|
+
if (bytes > contentLimitBytes)
|
|
240
|
+
fail('CALL_ARGUMENTS_INVALID', 422, `${field} is ${bytes} bytes, above the ${contentLimitBytes} byte limit for a single Call.`);
|
|
241
|
+
return value;
|
|
242
|
+
}
|
|
243
|
+
function processResult(content) {
|
|
244
|
+
const value = object(content);
|
|
245
|
+
const signal = optionalString(value.signal);
|
|
246
|
+
const exitCode = typeof value.exit_code === 'number' ? value.exit_code : -1;
|
|
247
|
+
const failed = exitCode !== 0 || signal !== undefined;
|
|
248
|
+
return {
|
|
249
|
+
content,
|
|
250
|
+
...(failed ? { outcome: 'failed', errorCode: signal === 'TIMEOUT' ? 'WORKER_TIMEOUT' : signal === 'CANCELLED' ? 'WORKER_CANCELLED' : 'WORKER_EXIT_NONZERO' } : {})
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
function object(value) {
|
|
254
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
255
|
+
fail('INVALID_INPUT', 400, 'Executor input must be an object.');
|
|
256
|
+
return value;
|
|
257
|
+
}
|
|
258
|
+
function string(value) {
|
|
259
|
+
if (typeof value !== 'string' || value.length === 0)
|
|
260
|
+
fail('INVALID_INPUT', 400, 'A required string is missing.');
|
|
261
|
+
return value;
|
|
262
|
+
}
|
|
263
|
+
function text(value) {
|
|
264
|
+
if (typeof value !== 'string')
|
|
265
|
+
fail('INVALID_INPUT', 400, 'A required text value is missing.');
|
|
266
|
+
return value;
|
|
267
|
+
}
|
|
268
|
+
function optionalString(value) {
|
|
269
|
+
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
|
270
|
+
}
|
|
271
|
+
function optionalNumber(value) {
|
|
272
|
+
return typeof value === 'number' && Number.isSafeInteger(value) && value > 0 ? value : undefined;
|
|
273
|
+
}
|
|
274
|
+
function strings(value) {
|
|
275
|
+
if (value === undefined)
|
|
276
|
+
return [];
|
|
277
|
+
if (!Array.isArray(value) || value.some((entry) => typeof entry !== 'string'))
|
|
278
|
+
fail('INVALID_INPUT', 400, 'args must be a string array.');
|
|
279
|
+
return value;
|
|
280
|
+
}
|
|
281
|
+
function stringRecord(value) {
|
|
282
|
+
if (value === undefined)
|
|
283
|
+
return {};
|
|
284
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value) || Object.values(value).some((entry) => typeof entry !== 'string'))
|
|
285
|
+
fail('INVALID_INPUT', 400, 'env must be a string map.');
|
|
286
|
+
return value;
|
|
287
|
+
}
|
|
288
|
+
function jsonValue(value) {
|
|
289
|
+
if (value === null || typeof value === 'string' || typeof value === 'boolean' || typeof value === 'number')
|
|
290
|
+
return value;
|
|
291
|
+
if (Array.isArray(value))
|
|
292
|
+
return value.map(jsonValue);
|
|
293
|
+
if (typeof value === 'object')
|
|
294
|
+
return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, jsonValue(entry)]));
|
|
295
|
+
fail('EXECUTOR_PROTOCOL_ERROR', 503, 'Executor returned a non-JSON value.');
|
|
296
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type CapabilityAdapter, type CapabilityInvocation, type CapabilityOutput, type Json } from '../core/domain.js';
|
|
2
|
+
import type { HarnessConfig } from '../config/schema.js';
|
|
3
|
+
import type { RegisteredCapability } from '../runtime/capabilities.js';
|
|
4
|
+
export declare class LspPool {
|
|
5
|
+
#private;
|
|
6
|
+
readonly servers: NonNullable<HarnessConfig['lspServers']>;
|
|
7
|
+
constructor(servers: NonNullable<HarnessConfig['lspServers']>);
|
|
8
|
+
request(serverId: string, workspacePaths: readonly string[], sessionId: string, method: string, params: Json, signal: AbortSignal): Promise<Json>;
|
|
9
|
+
projectPath(serverId: string, workspacePaths: readonly string[]): string;
|
|
10
|
+
close(): Promise<void>;
|
|
11
|
+
}
|
|
12
|
+
export declare class LspAdapter implements CapabilityAdapter {
|
|
13
|
+
readonly pool: LspPool;
|
|
14
|
+
readonly id = "lsp";
|
|
15
|
+
constructor(pool: LspPool);
|
|
16
|
+
execute(invocation: CapabilityInvocation): Promise<CapabilityOutput>;
|
|
17
|
+
}
|
|
18
|
+
export declare function lspCapabilities(config: NonNullable<HarnessConfig['lspServers']>, pool: LspPool): readonly RegisteredCapability[];
|