@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
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type ArtifactOutput } from '../core/domain.js';
|
|
2
|
+
import type { Document, Store } from '../store/store.js';
|
|
3
|
+
export declare class ArtifactStore {
|
|
4
|
+
readonly store: Store;
|
|
5
|
+
readonly mode: 'filesystem' | 'content_addressed';
|
|
6
|
+
readonly root: string;
|
|
7
|
+
constructor(root: string, directory: string, store: Store, mode: 'filesystem' | 'content_addressed');
|
|
8
|
+
save(projectId: string, artifacts: readonly ArtifactOutput[]): Promise<readonly string[]>;
|
|
9
|
+
read(artifact: Document): Promise<Buffer>;
|
|
10
|
+
remove(relativePaths: readonly string[]): Promise<{
|
|
11
|
+
readonly deleted: number;
|
|
12
|
+
readonly failed: number;
|
|
13
|
+
}>;
|
|
14
|
+
resolve(relativePath: string): string;
|
|
15
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { basename, dirname, relative, resolve, sep } from 'node:path';
|
|
4
|
+
import { gunzipSync, gzipSync } from 'node:zlib';
|
|
5
|
+
import { fail } from '../core/domain.js';
|
|
6
|
+
export class ArtifactStore {
|
|
7
|
+
store;
|
|
8
|
+
mode;
|
|
9
|
+
root;
|
|
10
|
+
constructor(root, directory, store, mode) {
|
|
11
|
+
this.store = store;
|
|
12
|
+
this.mode = mode;
|
|
13
|
+
this.root = resolve(root, directory);
|
|
14
|
+
}
|
|
15
|
+
// Bytes are gzipped on disk; `sha256`/`size_bytes` describe the original, uncompressed content
|
|
16
|
+
// (so identical logical content still dedups by hash regardless of compression, and API
|
|
17
|
+
// consumers see the size they'll actually get back from read()) rather than the stored form.
|
|
18
|
+
async save(projectId, artifacts) {
|
|
19
|
+
const ids = [];
|
|
20
|
+
for (const artifact of artifacts) {
|
|
21
|
+
const sha256 = createHash('sha256').update(artifact.bytes).digest('hex');
|
|
22
|
+
const relativePath = this.mode === 'content_addressed'
|
|
23
|
+
? `${sha256.slice(0, 2)}/${sha256}`
|
|
24
|
+
: `${projectId}/${sha256}-${safeName(artifact.name)}`;
|
|
25
|
+
const path = this.resolve(relativePath);
|
|
26
|
+
await mkdir(dirname(path), { recursive: true });
|
|
27
|
+
const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
28
|
+
await writeFile(temporary, gzipSync(artifact.bytes));
|
|
29
|
+
await rename(temporary, path);
|
|
30
|
+
const document = this.store.createArtifact(projectId, {
|
|
31
|
+
name: artifact.name,
|
|
32
|
+
mediaType: artifact.mediaType,
|
|
33
|
+
relativePath,
|
|
34
|
+
sha256,
|
|
35
|
+
sizeBytes: artifact.bytes.byteLength
|
|
36
|
+
});
|
|
37
|
+
ids.push(string(document.id));
|
|
38
|
+
}
|
|
39
|
+
return ids;
|
|
40
|
+
}
|
|
41
|
+
async read(artifact) {
|
|
42
|
+
return gunzipSync(await readFile(this.resolve(string(artifact.relative_path))));
|
|
43
|
+
}
|
|
44
|
+
async remove(relativePaths) {
|
|
45
|
+
const results = await Promise.allSettled(relativePaths.map((entry) => unlink(this.resolve(entry))));
|
|
46
|
+
return {
|
|
47
|
+
deleted: results.filter((entry) => entry.status === 'fulfilled' || fileMissing(entry)).length,
|
|
48
|
+
failed: results.filter((entry) => entry.status === 'rejected' && !fileMissing(entry)).length
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
resolve(relativePath) {
|
|
52
|
+
const path = resolve(this.root, relativePath);
|
|
53
|
+
const relation = relative(this.root, path);
|
|
54
|
+
if (relation === '..' || relation.startsWith(`..${sep}`))
|
|
55
|
+
fail('ARTIFACT_PATH_INVALID', 503, 'Artifact metadata escapes the Artifact root.');
|
|
56
|
+
return path;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
function fileMissing(result) {
|
|
60
|
+
if (result.status !== 'rejected')
|
|
61
|
+
return false;
|
|
62
|
+
const reason = result.reason;
|
|
63
|
+
return typeof reason === 'object' && reason !== null && 'code' in reason && reason.code === 'ENOENT';
|
|
64
|
+
}
|
|
65
|
+
function safeName(value) {
|
|
66
|
+
return basename(value).replace(/[^a-zA-Z0-9._-]/g, '_') || 'artifact';
|
|
67
|
+
}
|
|
68
|
+
function string(value) {
|
|
69
|
+
if (typeof value !== 'string')
|
|
70
|
+
fail('DATABASE_CORRUPT', 503, 'Artifact metadata is invalid.');
|
|
71
|
+
return value;
|
|
72
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { ModelMessage, ModelTool } from '../core/domain.js';
|
|
2
|
+
export interface CachePlanEntry {
|
|
3
|
+
readonly ordinal: number;
|
|
4
|
+
readonly container: 'message' | 'tool';
|
|
5
|
+
readonly stability: 'stable' | 'dynamic' | 'unknown';
|
|
6
|
+
readonly tokens: number;
|
|
7
|
+
readonly digest: string;
|
|
8
|
+
readonly prefixDigest: string;
|
|
9
|
+
readonly prefixReachable: boolean;
|
|
10
|
+
readonly breakpoint: boolean;
|
|
11
|
+
}
|
|
12
|
+
export interface CachePlan {
|
|
13
|
+
readonly status: 'planned' | 'disabled';
|
|
14
|
+
readonly fingerprintVersion: 1;
|
|
15
|
+
readonly providerVisibleFingerprint: string;
|
|
16
|
+
readonly barrierOrdinal: number | null;
|
|
17
|
+
readonly hintsApplied: false;
|
|
18
|
+
readonly entries: readonly CachePlanEntry[];
|
|
19
|
+
}
|
|
20
|
+
export declare function planCache(messages: readonly ModelMessage[], tools: readonly ModelTool[], enabled: boolean, minimumTokens: number, maximumBreakpoints: number, stability: Readonly<Record<string, 'stable' | 'dynamic' | 'unknown'>>): CachePlan;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { digest } from '../core/codec.js';
|
|
2
|
+
import { estimateTokens } from './tokenizer.js';
|
|
3
|
+
export function planCache(messages, tools, enabled, minimumTokens, maximumBreakpoints, stability) {
|
|
4
|
+
const segments = [
|
|
5
|
+
...messages.map((message) => ({
|
|
6
|
+
container: 'message',
|
|
7
|
+
stability: stability[message.role === 'system' ? 'system' : 'context'] ?? 'unknown',
|
|
8
|
+
value: message
|
|
9
|
+
})),
|
|
10
|
+
...tools.map((tool) => ({ container: 'tool', stability: stability.tools ?? 'unknown', value: tool }))
|
|
11
|
+
];
|
|
12
|
+
let prefix = '';
|
|
13
|
+
let barrier = null;
|
|
14
|
+
let breakpoints = 0;
|
|
15
|
+
const entries = segments.map((segment, ordinal) => {
|
|
16
|
+
const segmentDigest = digest(segment.value);
|
|
17
|
+
prefix = digest([prefix, segmentDigest]);
|
|
18
|
+
if (barrier === null && segment.stability !== 'stable')
|
|
19
|
+
barrier = ordinal;
|
|
20
|
+
const tokens = estimateTokens(JSON.stringify(segment.value)).expected;
|
|
21
|
+
const reachable = barrier === null;
|
|
22
|
+
const breakpoint = enabled && reachable && tokens >= minimumTokens && breakpoints < maximumBreakpoints;
|
|
23
|
+
if (breakpoint)
|
|
24
|
+
breakpoints += 1;
|
|
25
|
+
return {
|
|
26
|
+
ordinal,
|
|
27
|
+
container: segment.container,
|
|
28
|
+
stability: segment.stability,
|
|
29
|
+
tokens,
|
|
30
|
+
digest: segmentDigest,
|
|
31
|
+
prefixDigest: prefix,
|
|
32
|
+
prefixReachable: reachable,
|
|
33
|
+
breakpoint
|
|
34
|
+
};
|
|
35
|
+
});
|
|
36
|
+
return {
|
|
37
|
+
status: enabled ? 'planned' : 'disabled',
|
|
38
|
+
fingerprintVersion: 1,
|
|
39
|
+
providerVisibleFingerprint: digest(segments.map((segment) => segment.value)),
|
|
40
|
+
barrierOrdinal: barrier,
|
|
41
|
+
hintsApplied: false,
|
|
42
|
+
entries
|
|
43
|
+
};
|
|
44
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Document, Store } from '../store/store.js';
|
|
2
|
+
import type { ArtifactStore } from './artifacts.js';
|
|
3
|
+
import type { CapabilityRegistry } from './capabilities.js';
|
|
4
|
+
import type { Scheduler } from './scheduler.js';
|
|
5
|
+
import type { EventBus } from './events.js';
|
|
6
|
+
export declare class CallRuntime {
|
|
7
|
+
#private;
|
|
8
|
+
readonly store: Store;
|
|
9
|
+
readonly registry: CapabilityRegistry;
|
|
10
|
+
readonly scheduler: Scheduler;
|
|
11
|
+
readonly artifacts: ArtifactStore;
|
|
12
|
+
readonly events: EventBus;
|
|
13
|
+
constructor(store: Store, registry: CapabilityRegistry, scheduler: Scheduler, artifacts: ArtifactStore, events: EventBus);
|
|
14
|
+
dispatch(projectId: string, callId: string, parentSignal?: AbortSignal): void;
|
|
15
|
+
cancel(projectId: string, callId: string, expectedRevision?: number): {
|
|
16
|
+
readonly call: Document;
|
|
17
|
+
readonly result: Document | null;
|
|
18
|
+
};
|
|
19
|
+
waitAll(projectId: string, callIds: readonly string[], signal: AbortSignal): Promise<readonly Document[]>;
|
|
20
|
+
close(): Promise<void>;
|
|
21
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { LensError, UserInputRequiredError, fail } from '../core/domain.js';
|
|
2
|
+
import { McpElicitationError } from '../adapters/mcp.js';
|
|
3
|
+
import { normalizeOutput } from './results.js';
|
|
4
|
+
export class CallRuntime {
|
|
5
|
+
store;
|
|
6
|
+
registry;
|
|
7
|
+
scheduler;
|
|
8
|
+
artifacts;
|
|
9
|
+
events;
|
|
10
|
+
#running = new Map();
|
|
11
|
+
#controllers = new Map();
|
|
12
|
+
constructor(store, registry, scheduler, artifacts, events) {
|
|
13
|
+
this.store = store;
|
|
14
|
+
this.registry = registry;
|
|
15
|
+
this.scheduler = scheduler;
|
|
16
|
+
this.artifacts = artifacts;
|
|
17
|
+
this.events = events;
|
|
18
|
+
}
|
|
19
|
+
dispatch(projectId, callId, parentSignal) {
|
|
20
|
+
if (this.#running.has(callId))
|
|
21
|
+
return;
|
|
22
|
+
const controller = new AbortController();
|
|
23
|
+
parentSignal?.addEventListener('abort', () => controller.abort(parentSignal.reason), { once: true });
|
|
24
|
+
this.#controllers.set(callId, controller);
|
|
25
|
+
const running = this.#execute(projectId, callId, controller.signal)
|
|
26
|
+
.finally(() => {
|
|
27
|
+
this.#running.delete(callId);
|
|
28
|
+
this.#controllers.delete(callId);
|
|
29
|
+
});
|
|
30
|
+
this.#running.set(callId, running);
|
|
31
|
+
}
|
|
32
|
+
cancel(projectId, callId, expectedRevision) {
|
|
33
|
+
const decision = this.store.decideCall(projectId, callId, 'cancel', expectedRevision);
|
|
34
|
+
this.#controllers.get(callId)?.abort(new Error('Call cancelled.'));
|
|
35
|
+
this.scheduler.cancel(callId);
|
|
36
|
+
return decision;
|
|
37
|
+
}
|
|
38
|
+
async waitAll(projectId, callIds, signal) {
|
|
39
|
+
while (!signal.aborted) {
|
|
40
|
+
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;
|
|
46
|
+
}
|
|
47
|
+
async close() {
|
|
48
|
+
for (const controller of this.#controllers.values())
|
|
49
|
+
controller.abort(new Error('Call runtime closed.'));
|
|
50
|
+
await Promise.allSettled(this.#running.values());
|
|
51
|
+
}
|
|
52
|
+
async #execute(projectId, callId, signal) {
|
|
53
|
+
let call = this.store.getCall(projectId, callId);
|
|
54
|
+
if (call.state !== 'QUEUED')
|
|
55
|
+
return;
|
|
56
|
+
let acquired = false;
|
|
57
|
+
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);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
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)) {
|
|
92
|
+
current = this.store.waitCall(projectId, callId, error.prompt);
|
|
93
|
+
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
|
+
}
|
|
95
|
+
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));
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
finally {
|
|
101
|
+
if (acquired)
|
|
102
|
+
this.scheduler.release(callId);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
function errorCode(error) {
|
|
107
|
+
if (error instanceof LensError)
|
|
108
|
+
return error.code;
|
|
109
|
+
if (typeof error === 'object' && error !== null && 'code' in error && typeof error.code === 'string')
|
|
110
|
+
return error.code;
|
|
111
|
+
return 'CAPABILITY_FAILED';
|
|
112
|
+
}
|
|
113
|
+
function string(value) {
|
|
114
|
+
if (typeof value !== 'string')
|
|
115
|
+
fail('DATABASE_CORRUPT', 503, 'A Call field is invalid.');
|
|
116
|
+
return value;
|
|
117
|
+
}
|
|
118
|
+
function optionalString(value) {
|
|
119
|
+
return typeof value === 'string' ? value : undefined;
|
|
120
|
+
}
|
|
121
|
+
function jsonValue(value) {
|
|
122
|
+
if (value === undefined)
|
|
123
|
+
fail('DATABASE_CORRUPT', 503, 'Call input is missing.');
|
|
124
|
+
return value;
|
|
125
|
+
}
|
|
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;
|
|
130
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { type AdapterManifest, type CapabilityAdapter, type CapabilityDefinition, type CapabilityInvocation, type CapabilityOutput, type EffectScope, type Json } from '../core/domain.js';
|
|
2
|
+
import { type AdapterTarget } from './manifests.js';
|
|
3
|
+
export interface ResourceClaim {
|
|
4
|
+
readonly resource: string;
|
|
5
|
+
readonly mode: 'read' | 'write';
|
|
6
|
+
}
|
|
7
|
+
export interface ClaimContext {
|
|
8
|
+
readonly projectId: string;
|
|
9
|
+
readonly workspaceId: string;
|
|
10
|
+
readonly workspacePaths: readonly string[];
|
|
11
|
+
readonly arguments: Json;
|
|
12
|
+
}
|
|
13
|
+
export interface RegisteredCapability {
|
|
14
|
+
readonly definition: CapabilityDefinition;
|
|
15
|
+
readonly adapter: CapabilityAdapter;
|
|
16
|
+
readonly manifest: AdapterManifest;
|
|
17
|
+
effect?(argumentsValue: Json): EffectScope;
|
|
18
|
+
claims(context: ClaimContext): readonly ResourceClaim[];
|
|
19
|
+
}
|
|
20
|
+
export declare class CapabilityRegistry {
|
|
21
|
+
#private;
|
|
22
|
+
readonly target: AdapterTarget;
|
|
23
|
+
constructor(target: AdapterTarget);
|
|
24
|
+
register(capability: RegisteredCapability): void;
|
|
25
|
+
removeByAdapter(adapterId: string): void;
|
|
26
|
+
list(): readonly CapabilityDefinition[];
|
|
27
|
+
/**
|
|
28
|
+
* `CAP-01` `CapabilityCatalogEntry`: the documented, safe projection of the Registry.
|
|
29
|
+
*
|
|
30
|
+
* The Route used to return the internal `CapabilityDefinition` instead, which both used
|
|
31
|
+
* camelCase names the contract does not define -- so a client reading `capability_id`,
|
|
32
|
+
* `short_description` or `load_mode` got nothing -- and published the adapter id, the input JSON
|
|
33
|
+
* Schema and the output limit, none of which the catalog entry contains. Ordering follows 02A:
|
|
34
|
+
* `kind`, then `capability_id`, then `definition_version`.
|
|
35
|
+
*/
|
|
36
|
+
catalog(): readonly Readonly<Record<string, Json>>[];
|
|
37
|
+
manifests(): readonly AdapterManifest[];
|
|
38
|
+
removeKind(kind: NonNullable<CapabilityDefinition['kind']>): void;
|
|
39
|
+
/**
|
|
40
|
+
* The manifests that make up the runtime contract.
|
|
41
|
+
*
|
|
42
|
+
* Workspace Skills are excluded deliberately. They are Project-local file content, so including
|
|
43
|
+
* them made `runtime_contract_digest` -- which `/health/ready` publishes with no Project scope --
|
|
44
|
+
* depend on which Project was asked. Two Projects with different Skills then reported different
|
|
45
|
+
* values for the same global field, and the handshake check comparing readiness against
|
|
46
|
+
* `subscription.ready` could never pass for a Project whose Skills differed from the default
|
|
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.
|
|
49
|
+
*/
|
|
50
|
+
contractManifests(): readonly AdapterManifest[];
|
|
51
|
+
get(capabilityId: string): CapabilityDefinition;
|
|
52
|
+
find(capabilityId: string): CapabilityDefinition | undefined;
|
|
53
|
+
search(query: string, limit: number): readonly CapabilityDefinition[];
|
|
54
|
+
claims(capabilityId: string, context: ClaimContext): readonly ResourceClaim[];
|
|
55
|
+
effect(capabilityId: string, argumentsValue: Json): EffectScope;
|
|
56
|
+
execute(invocation: CapabilityInvocation, validationArguments?: Json): Promise<CapabilityOutput>;
|
|
57
|
+
}
|
|
58
|
+
export declare function claimsConflict(left: readonly ResourceClaim[], right: readonly ResourceClaim[]): boolean;
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { isAbsolute, resolve, sep } from 'node:path';
|
|
2
|
+
import { Ajv } from 'ajv';
|
|
3
|
+
import { fail } from '../core/domain.js';
|
|
4
|
+
import { assertCapabilityManifest } from './manifests.js';
|
|
5
|
+
export class CapabilityRegistry {
|
|
6
|
+
target;
|
|
7
|
+
#entries = new Map();
|
|
8
|
+
#ajv = new Ajv({ allErrors: true, strict: false });
|
|
9
|
+
constructor(target) {
|
|
10
|
+
this.target = target;
|
|
11
|
+
}
|
|
12
|
+
register(capability) {
|
|
13
|
+
if (this.#entries.has(capability.definition.id))
|
|
14
|
+
fail('CAPABILITY_DUPLICATE', 503, `Capability ${capability.definition.id} is already registered.`);
|
|
15
|
+
assertCapabilityManifest(capability.manifest, capability.definition, this.target);
|
|
16
|
+
this.#entries.set(capability.definition.id, {
|
|
17
|
+
...capability,
|
|
18
|
+
validate: this.#ajv.compile(capability.definition.inputSchema),
|
|
19
|
+
...(capability.definition.outputSchema === undefined ? {} : { validateOutput: this.#ajv.compile(capability.definition.outputSchema) })
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
removeByAdapter(adapterId) {
|
|
23
|
+
for (const [capabilityId, entry] of this.#entries) {
|
|
24
|
+
if (entry.definition.adapterId === adapterId)
|
|
25
|
+
this.#entries.delete(capabilityId);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
list() {
|
|
29
|
+
return [...this.#entries.values()].map((entry) => entry.definition).sort((left, right) => left.id.localeCompare(right.id));
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* `CAP-01` `CapabilityCatalogEntry`: the documented, safe projection of the Registry.
|
|
33
|
+
*
|
|
34
|
+
* The Route used to return the internal `CapabilityDefinition` instead, which both used
|
|
35
|
+
* camelCase names the contract does not define -- so a client reading `capability_id`,
|
|
36
|
+
* `short_description` or `load_mode` got nothing -- and published the adapter id, the input JSON
|
|
37
|
+
* Schema and the output limit, none of which the catalog entry contains. Ordering follows 02A:
|
|
38
|
+
* `kind`, then `capability_id`, then `definition_version`.
|
|
39
|
+
*/
|
|
40
|
+
catalog() {
|
|
41
|
+
return [...this.#entries.values()]
|
|
42
|
+
.map((entry) => ({
|
|
43
|
+
capability_id: entry.definition.id,
|
|
44
|
+
kind: entry.definition.kind ?? 'tool',
|
|
45
|
+
name: entry.definition.title,
|
|
46
|
+
short_description: entry.definition.description,
|
|
47
|
+
// The definition carries no hint list; an empty array states that rather than inventing
|
|
48
|
+
// tokens that would then rank search results.
|
|
49
|
+
search_hints: [],
|
|
50
|
+
load_mode: entry.definition.loadMode,
|
|
51
|
+
model_surfaces: entry.definition.promptText === undefined
|
|
52
|
+
? ['callable_schema']
|
|
53
|
+
: ['callable_schema', 'prompt_instruction'],
|
|
54
|
+
definition_version: entry.manifest.version,
|
|
55
|
+
definition_digest: entry.manifest.configSchemaDigest
|
|
56
|
+
}))
|
|
57
|
+
.sort((left, right) => left.kind.localeCompare(right.kind)
|
|
58
|
+
|| left.capability_id.localeCompare(right.capability_id)
|
|
59
|
+
|| left.definition_version.localeCompare(right.definition_version));
|
|
60
|
+
}
|
|
61
|
+
manifests() {
|
|
62
|
+
return [...this.#entries.values()].map((entry) => entry.manifest).sort((left, right) => left.id.localeCompare(right.id));
|
|
63
|
+
}
|
|
64
|
+
removeKind(kind) {
|
|
65
|
+
for (const [capabilityId, entry] of this.#entries) {
|
|
66
|
+
if (entry.definition.kind === kind)
|
|
67
|
+
this.#entries.delete(capabilityId);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* The manifests that make up the runtime contract.
|
|
72
|
+
*
|
|
73
|
+
* Workspace Skills are excluded deliberately. They are Project-local file content, so including
|
|
74
|
+
* them made `runtime_contract_digest` -- which `/health/ready` publishes with no Project scope --
|
|
75
|
+
* depend on which Project was asked. Two Projects with different Skills then reported different
|
|
76
|
+
* values for the same global field, and the handshake check comparing readiness against
|
|
77
|
+
* `subscription.ready` could never pass for a Project whose Skills differed from the default
|
|
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.
|
|
80
|
+
*/
|
|
81
|
+
contractManifests() {
|
|
82
|
+
const contract = new Set(this.list().filter((definition) => definition.kind !== 'skill').map((definition) => definition.id));
|
|
83
|
+
return this.manifests().filter((manifest) => manifest.capabilities.every((capability) => contract.has(capability)));
|
|
84
|
+
}
|
|
85
|
+
get(capabilityId) {
|
|
86
|
+
return this.#entry(capabilityId).definition;
|
|
87
|
+
}
|
|
88
|
+
find(capabilityId) {
|
|
89
|
+
return this.#entries.get(capabilityId)?.definition;
|
|
90
|
+
}
|
|
91
|
+
search(query, limit) {
|
|
92
|
+
const words = query.toLowerCase().split(/\s+/).filter(Boolean);
|
|
93
|
+
return this.list()
|
|
94
|
+
.map((entry) => ({ entry, score: words.reduce((score, word) => score + `${entry.id} ${entry.title} ${entry.description}`.toLowerCase().split(word).length - 1, 0) }))
|
|
95
|
+
.filter(({ score }) => score > 0)
|
|
96
|
+
.sort((left, right) => right.score - left.score || left.entry.id.localeCompare(right.entry.id))
|
|
97
|
+
.slice(0, limit)
|
|
98
|
+
.map(({ entry }) => entry);
|
|
99
|
+
}
|
|
100
|
+
// Any failure to work out the resources falls back to a global write claim: AGENTS.md requires the
|
|
101
|
+
// conservative answer when concurrency safety cannot be determined.
|
|
102
|
+
claims(capabilityId, context) {
|
|
103
|
+
const entry = this.#validated(capabilityId, context.arguments);
|
|
104
|
+
try {
|
|
105
|
+
return entry.claims(context);
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return [{ resource: '*', mode: 'write' }];
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
effect(capabilityId, argumentsValue) {
|
|
112
|
+
const entry = this.#validated(capabilityId, argumentsValue);
|
|
113
|
+
const effect = entry.effect?.(argumentsValue) ?? entry.definition.effect;
|
|
114
|
+
if (!entry.manifest.effectScopes.includes(effect))
|
|
115
|
+
fail('ADAPTER_INCOMPATIBLE', 503, `Adapter manifest ${entry.manifest.id} does not cover effect ${effect}.`);
|
|
116
|
+
return effect;
|
|
117
|
+
}
|
|
118
|
+
async execute(invocation, validationArguments = invocation.arguments) {
|
|
119
|
+
const entry = this.#validated(invocation.capabilityId, validationArguments);
|
|
120
|
+
const output = await entry.adapter.execute(invocation);
|
|
121
|
+
if (entry.validateOutput !== undefined) {
|
|
122
|
+
const content = jsonObject(output.content);
|
|
123
|
+
const structured = content?.structured_content;
|
|
124
|
+
if (structured !== undefined && !entry.validateOutput(structured)) {
|
|
125
|
+
fail('RESULT_NORMALIZATION_FAILED', 422, 'Capability output does not match the declared Schema.');
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return output;
|
|
129
|
+
}
|
|
130
|
+
#validated(capabilityId, argumentsValue) {
|
|
131
|
+
const entry = this.#entry(capabilityId);
|
|
132
|
+
if (!entry.validate(argumentsValue)) {
|
|
133
|
+
fail('CAPABILITY_ARGUMENT_INVALID', 400, 'Capability arguments do not match the declared Schema.', {
|
|
134
|
+
capability_id: capabilityId,
|
|
135
|
+
issues: (entry.validate.errors ?? []).map((error) => ({ path: error.instancePath, message: error.message ?? 'invalid' }))
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
return entry;
|
|
139
|
+
}
|
|
140
|
+
#entry(capabilityId) {
|
|
141
|
+
const entry = this.#entries.get(capabilityId);
|
|
142
|
+
if (entry === undefined)
|
|
143
|
+
fail('CAPABILITY_NOT_FOUND', 404, `Capability ${capabilityId} is not registered.`);
|
|
144
|
+
return entry;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
function jsonObject(value) {
|
|
148
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
149
|
+
return null;
|
|
150
|
+
return value;
|
|
151
|
+
}
|
|
152
|
+
export function claimsConflict(left, right) {
|
|
153
|
+
for (const first of left) {
|
|
154
|
+
for (const second of right) {
|
|
155
|
+
if (!resourcesOverlap(first.resource, second.resource))
|
|
156
|
+
continue;
|
|
157
|
+
if (first.mode === 'write' || second.mode === 'write')
|
|
158
|
+
return true;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
// Two resources overlap when a Call touching one can affect the other. '*' covers everything.
|
|
164
|
+
//
|
|
165
|
+
// Filesystem resources overlap when either contains the other: a Call claiming a directory --
|
|
166
|
+
// terminal_command_run's cwd, or glob/grep over a Workspace root -- must conflict with one claiming a
|
|
167
|
+
// file inside it, even though the strings differ. Exact string equality missed that entirely, so a
|
|
168
|
+
// command running in a directory could execute in parallel with a write to a file under it.
|
|
169
|
+
//
|
|
170
|
+
// Non-path resources (`network`, `mcp:<id>`, `agent:<id>`) only ever match exactly; prefix logic there
|
|
171
|
+
// would make `mcp:a` collide with `mcp:ab`.
|
|
172
|
+
//
|
|
173
|
+
// This is the "父子目录重叠" rule from 05 调用、执行和 Result. It can only add conflicts, never hide
|
|
174
|
+
// one, which is the direction AGENTS.md requires when concurrency safety is uncertain -- at the cost
|
|
175
|
+
// of serializing a Workspace-wide grep against any write in that Workspace.
|
|
176
|
+
function resourcesOverlap(left, right) {
|
|
177
|
+
if (left === '*' || right === '*')
|
|
178
|
+
return true;
|
|
179
|
+
const first = fileKey(left);
|
|
180
|
+
const second = fileKey(right);
|
|
181
|
+
if (first === undefined || second === undefined)
|
|
182
|
+
return left === right;
|
|
183
|
+
return first === second || contains(first, second) || contains(second, first);
|
|
184
|
+
}
|
|
185
|
+
// Case folded wherever the platform's filesystem is case-insensitive, so /ws/A.ts and /ws/a.ts are one
|
|
186
|
+
// resource on Windows and macOS but two on Linux. Folding can only add conflicts, so a case-sensitive
|
|
187
|
+
// volume on macOS loses parallelism rather than safety.
|
|
188
|
+
function fileKey(resource) {
|
|
189
|
+
if (!isAbsolute(resource))
|
|
190
|
+
return undefined;
|
|
191
|
+
const normalized = resolve(resource);
|
|
192
|
+
return process.platform === 'linux' ? normalized : normalized.toLowerCase();
|
|
193
|
+
}
|
|
194
|
+
function contains(parent, child) {
|
|
195
|
+
return child.startsWith(parent.endsWith(sep) ? parent : `${parent}${sep}`);
|
|
196
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { ExecutorClient } from '../adapters/executor.js';
|
|
2
|
+
import type { Document } from '../store/store.js';
|
|
3
|
+
import type { ArtifactStore } from './artifacts.js';
|
|
4
|
+
export declare function applyPurge(purged: Document, artifacts: ArtifactStore, executor: ExecutorClient): Promise<{
|
|
5
|
+
readonly deleted: number;
|
|
6
|
+
readonly failed: number;
|
|
7
|
+
}>;
|
|
@@ -0,0 +1,33 @@
|
|
|
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);
|
|
17
|
+
}
|
|
18
|
+
return files;
|
|
19
|
+
}
|
|
20
|
+
function byWorkspace(value) {
|
|
21
|
+
const grouped = new Map();
|
|
22
|
+
if (!Array.isArray(value))
|
|
23
|
+
return grouped;
|
|
24
|
+
for (const entry of value) {
|
|
25
|
+
if (typeof entry !== 'object' || entry === null || Array.isArray(entry))
|
|
26
|
+
continue;
|
|
27
|
+
const record = entry;
|
|
28
|
+
if (typeof record.workspace_id !== 'string' || typeof record.call_id !== 'string')
|
|
29
|
+
continue;
|
|
30
|
+
grouped.set(record.workspace_id, [...grouped.get(record.workspace_id) ?? [], record.call_id]);
|
|
31
|
+
}
|
|
32
|
+
return grouped;
|
|
33
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { type Json, type ModelMessage } from '../core/domain.js';
|
|
2
|
+
import type { Store } from '../store/store.js';
|
|
3
|
+
import type { HarnessConfig } from '../config/schema.js';
|
|
4
|
+
import type { Document } from '../store/store.js';
|
|
5
|
+
import type { ResolvedProfile } from './profile.js';
|
|
6
|
+
import type { CapabilityRegistry } from './capabilities.js';
|
|
7
|
+
import { type TokenEstimate } from './tokenizer.js';
|
|
8
|
+
export interface ContextDecision {
|
|
9
|
+
readonly stage: 'tool_result_budget' | 'snip' | 'microcompact' | 'context_collapse';
|
|
10
|
+
readonly sourceTokens: number;
|
|
11
|
+
readonly projectedTokens: number;
|
|
12
|
+
}
|
|
13
|
+
export interface PreparedContext {
|
|
14
|
+
readonly source: ContextSource;
|
|
15
|
+
readonly messages: readonly ModelMessage[];
|
|
16
|
+
readonly estimate: TokenEstimate;
|
|
17
|
+
readonly sourceRevision: number;
|
|
18
|
+
readonly digest: string;
|
|
19
|
+
readonly decisions: readonly ContextDecision[];
|
|
20
|
+
}
|
|
21
|
+
export interface ContextSource {
|
|
22
|
+
readonly agent: Document;
|
|
23
|
+
readonly items: readonly Document[];
|
|
24
|
+
readonly overrides: readonly Document[];
|
|
25
|
+
readonly capabilities: readonly Document[];
|
|
26
|
+
}
|
|
27
|
+
export declare class ContextEngine {
|
|
28
|
+
readonly store: Store;
|
|
29
|
+
readonly registry: CapabilityRegistry;
|
|
30
|
+
constructor(store: Store, registry: CapabilityRegistry);
|
|
31
|
+
capture(projectId: string, agentId: string): ContextSource;
|
|
32
|
+
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;
|
|
34
|
+
}
|