UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

307 lines (306 loc) • 20.9 kB
import { type RootsSeat } from "../tools/fs/safety.js"; import { type ReadDenyMatcher } from "../tools/fs/read-deny.js"; import type { HomeViewsCarrier } from "../tools/fs/read-deny-home.js"; import type { ContainmentFace } from "../tools/fs/containment-face.js"; import type { AgentTool, BoundFileObject, ExecutionEnv } from "../internal/harness-types.js"; /** * LSP code-intelligence tool (design/64 §13.1) — the app-dev命门 (go-to-def / find-refs / hover across * Dart/Kotlin/TS). A single `lsp` tool with a 9-operation enum, built over a pluggable {@link LspServerManager}. * * **Seam note (core verifying the design against real code):** the design assumed core spawns a language * server inside the ExecutionEnv via `execStream`, but `execStream` is OUTPUT-only — it has no stdin, so the * base seam can't drive a persistent stdio language server generically. So core ships the **tool + manager * seam + graceful degrade + git-ignore filter + formatters**; the *real* in-env manager (E2B LSP sidecar, or * a future bidirectional-process seam) is deployment-provided via {@link RunnerDeps.lspManager}. When no * server is available for a file's language the tool **degrades gracefully** ("fall back to Grep/Read") * rather than erroring — the design's hard constraint for remote portability. */ export type LspOperation = "goToDefinition" | "findReferences" | "hover" | "documentSymbol" | "workspaceSymbol" | "goToImplementation" | "prepareCallHierarchy" | "incomingCalls" | "outgoingCalls"; export declare const LSP_OPERATIONS: readonly LspOperation[]; /** * A source location (a definition/reference/implementation hit). `uri` is a file path or file:// URI. * * Base convention (the seam's factual contract, service[64] flag①): `line` is **1-based**, `character` * is **0-based** — the tool-schema convention the model is taught. Core passes positions through * VERBATIM in both directions; the `LspServerManager` implementation owns the conversion to/from the * LSP wire protocol (which is 0-based for BOTH) — e.g. request line−1 on the way in, result line+1 on * the way out, call-hierarchy items relayed as wire values it already converted. */ export interface LspLocation { uri: string; /** 1-based (tool convention — manager converts to the 0-based LSP wire). */ line: number; /** 0-based (matches the LSP wire). */ character: number; /** Optional one-line preview of the matched line. */ preview?: string; } /** A symbol (document/workspace symbol, call-hierarchy item). Same base convention as {@link LspLocation}. */ export interface LspSymbolInfo { name: string; kind?: string; uri: string; /** 1-based (tool convention — manager converts to the 0-based LSP wire). */ line: number; /** 0-based (matches the LSP wire). */ character: number; /** * HRD-LSP-18: the server supplied NO range for this symbol — `line`/`character` are placeholders, not a * location. LSP allows exactly this for an unresolved `WorkspaceSymbol` (`{ name, kind, location: { uri } }`, * resolved later via `workspaceSymbol/resolve`), and the coercion below used to substitute `{0,0}` silently, * which the tool then rendered as a precise-looking `uri:1:1` the server never said. Set ⇒ the renderer * prints the file WITHOUT coordinates. */ positionUnknown?: boolean; } /** * WHY a "none" result was "none". Absent `reason` = a GENUINE empty answer (the server * answered null/[] — today's behavior byte-for-byte, and what an external manager returning bare * `{kind:"none"}` keeps getting). The failure reasons exist because one literal "No results." used to cover * both real emptiness and every transport-level fault — a findReferences that TIMED OUT read as "no * references, safe to delete" (CC 2.1.220 keeps these apart: `Error performing ${operation}: ...` vs the * plain empty-result strings, (pretty220.js:474840, historical; not relocated in 250 — see cc-250 anchors)). */ export type LspNoneReason = "unsupported_operation" | "server_error" | "timeout" | "cancelled" | "server_terminated" | "no_call_hierarchy_item" /** HRD-LSP-7: the QUERIED file could not be read for the didOpen/didChange re-sync (deleted between the * tool's precheck and the op, EACCES, non-regular, over the size cap, a symlinked over-cap target). The * session used to swallow that and query the server anyway, so the resulting `null`/`[]` was * indistinguishable from a confirmed-empty answer — the exact confusion the reason set exists to end. */ | "file_sync_failed"; export type LspResult = { kind: "locations"; locations: LspLocation[]; } | { kind: "symbols"; symbols: LspSymbolInfo[]; } | { kind: "hover"; contents: string; } | { kind: "none"; reason?: LspNoneReason; detail?: string; }; /** The property {@link brandLspFailure} sets — a STRING property, not a class: the brand must survive a * dual ESM/CJS build and duplicated module instances, where `instanceof` does not (same structural- * detection philosophy as {@link pathToUri}'s form detection — never key on identity you don't control). */ export declare const LSP_FAILURE_BRAND = "lspFailureReason"; /** Brand a transport-layer error with its {@link LspNoneReason} at the THROW site — the throw site is the * only place that still holds the discriminator (e.g. the JSON-RPC error CODE, which message-regex * classification cannot recover, and which differs textually across transports). */ export declare function brandLspFailure<E extends Error>(e: E, reason: LspNoneReason, detail?: string): E; /** Duck-typed read of {@link brandLspFailure}'s brand (membership-checked — an arbitrary string-valued * property of the same name on a third-party error never becomes a reason we didn't define). */ export declare function lspFailureOf(e: unknown): { reason: LspNoneReason; detail?: string; } | undefined; /** * The abort signal for work shared by several callers: it fires only once EVERY registered caller has * abandoned it, so one caller's cancellation can never cancel another caller's work. A joiner that * passes no signal never abandons (the job then runs to completion regardless of the others). * * The scope is single-use: once it has aborted, the owner must NOT let a new caller join it (the job it * guards is already dying) — check {@link signal}`.aborted` before reusing an in-flight entry. */ export declare class SharedAbortScope { private readonly controller; private joiners; private abandoned; get signal(): AbortSignal; /** Register one caller (its `signal` may be absent = it never abandons). */ join(signal?: AbortSignal): void; private abandon; } /** * Give ONE caller its own settle on a shared job: the caller's `signal` resolves it with `onAbort()` * without disturbing the job (which other callers may still need). The job's own settle is always * observed, so a later rejection can never surface as an unhandled rejection. */ export declare function settleOnAbort<T>(job: Promise<T>, signal: AbortSignal | undefined, onAbort: () => T): Promise<T>; /** * HRD-LSP-5: resolve a model-supplied `filePath` against the TASK's cwd, once, for every leg. * * The tool used to join a relative path to `env.cwd` for the existence/size PRECHECK only and hand the * ORIGINAL string to `sessionFor` → `readText`/`pathToUri`, which resolve against the HOST process cwd. * With a task cwd that is not the host cwd (worktree/sandbox/service worker — i.e. every non-trivial * deployment) the two legs then addressed two different files: the precheck passed on the real file * while the server was handed `file:///<host-cwd>/<rel>`, and the model got a confident "No results." * for a file that was never analyzed. * * Absolute-form input (either family) passes the join; "no cwd known" keeps the caller's spelling. * * REF-C3 — the result is dot-folded, which is what makes this function THE key-minting seam: every URI * for a file (didOpen, publishDiagnostics, the diagnostics registry's `fileEdited` clear, buildRequest) * comes from here, and `pathToUri`'s POSIX branch is a hand-assembled per-segment encode that keeps a * `.` segment verbatim. Without the fold, `./src/a.ts` and `src/a.ts` minted two keys for one file: * delivered diagnostics never cleared, and the server held a duplicate document. The edit-nudge leg in * prepare-task.ts had grown its own copy of the fold for exactly this reason; it now calls this instead, * so "one file, one key" is a property of the code rather than of two spellings staying in step. */ export declare function resolveLspPath(filePath: string, cwd?: string): string; /** Request positions use the same base convention as {@link LspLocation}: line 1-based, character 0-based. */ export interface LspRequestParams { filePath: string; /** 1-based (tool convention) — the manager converts to the 0-based LSP wire (line−1). */ line?: number; /** 0-based (matches the LSP wire). */ character?: number; /** For workspaceSymbol: the symbol query string. */ symbol?: string; } /** An active language-server session for one language, scoped to a workspace. */ export interface LspSession { /** `gate` = the caller's read face at the session's file reads ({@link LspReadGate}); absent ⇒ a caller with none. */ request(op: LspOperation, params: LspRequestParams, signal?: AbortSignal, gate?: LspReadGate): Promise<LspResult>; } /** What a session's read gate answered for one file: the object its judgment named, opened and HELD (the environment's * `openBound`) under the canonical key it was judged as — the session reads that object, never the name again, and closes * it — or a refusal with its reason. */ export type LspReadAdmission = { readonly ok: true; readonly key: string; readonly object: BoundFileObject; } | { readonly ok: false; readonly reason: string; }; /** May a session read `filePath` (a path {@link resolveLspPath} minted) for this caller, now — and if so, WHAT: the gate * admits an object, not a name ({@link LspReadAdmission}). A session reads a file only from the object its caller's gate * admitted, and closes an opened document the gate does not admit. */ export type LspReadGate = (filePath: string, signal?: AbortSignal) => Promise<LspReadAdmission>; /** The read face one LSP mount reads under: the seats the fs read tools take, so the gate and the tool resolve and judge * a `filePath` alike (see {@link LspToolOptions.readFace}). */ export interface LspReadFace { /** The canonical task root: the primary containment root, and the base when no working directory is tracked. */ readonly rootCanonical: string; /** The extra read roots (write-capable and read-only), read per call. */ readonly roots?: RootsSeat; /** The tracked working directory; `current` is read per call as the base of a relative `filePath`. */ readonly cwdRef?: { readonly current: string; }; /** The read-deny table in force, with its placement's home views. */ readonly readDeny?: ReadDenyMatcher & HomeViewsCarrier; /** The read containment face. Absent ⇒ `roots`. */ readonly face?: ContainmentFace; /** The file read station's admission: an out-of-root target the gate asked about and let through, read once. */ readonly admitted?: (toolCallId: string | undefined, canonicalKey: string) => boolean; /** Settles once the read-deny table and read roots a judgment takes are ready (a Runner compiles them per read of the * person's rules): a call's own (`toolCallId`), or — with none — the run's newest; at `signal` too; never rejects; * absent ⇒ ready. The tool waits for it before it takes its call's; the session's own reads before they judge. */ readonly ready?: (toolCallId: string | undefined, signal?: AbortSignal) => Promise<void>; } /** The base a relative `filePath` resolves against: the catalog declaration through the one selector the fences read it * with; with no read face the env's working directory is the only base there is. */ export declare function lspBaseOf(readFace: LspReadFace | undefined, env?: ExecutionEnv): string | undefined; /** The session gate over a read face — the bound read a user attachment makes, at the session's read: the name is judged * as `Read` judges its target (a judgment that throws refuses), the object the judgment named is opened and held * (`openBound`; an environment without it cannot bind and refuses), and the name, judged again, must still land on the * held key. The admission carries the held object; the session reads only it. `authorizedKey` = the canonical key this * call's target was authorized as (the station's one-read admission already spent on it): a spelling that still lands * there passes outside the roots, nothing else does. */ export declare function lspReadGateOf(env: ExecutionEnv, face: LspReadFace, authorizedKey?: string): LspReadGate; /** Why a file the language server named is withheld: a read-deny row reaches one of its views, or its views could not be * read (`unreadable` — the judgment's absence word). */ export type LspListedWithholding = { readonly pattern: string; } | { readonly unreadable: true; }; /** * The files a language server named (result locations, symbols, drained diagnostics), judged as ONE bounded batch against * the read face's deny table on every view of each file — the spelling, the canonical target, the home view and the link * hops, through the same `resolveKey` a read target meets (containment is not this question: the face is judged open). * Fail-closed: a file whose views cannot be read, and every file when the batch outlives the gate's deadline, is withheld. * Kept files are absent from the result. */ export declare function lspListedWithheld(env: ExecutionEnv, face: LspReadFace, uris: readonly string[], signal?: AbortSignal): Promise<ReadonlyMap<string, LspListedWithholding>>; /** * A JSON-RPC transport to ONE language server, POST-`initialize`. The DEPLOYMENT provides the concrete * transport; the protocol logic ({@link buildRequest}/`parseResult`, `TransportLspSession`) drives the LSP * over this single seam, so the two lanes can't drift: * - **TOC** = {@link StdioLspTransport} (spawn the server LOCALLY; Content-Length JSON-RPC over its stdio — a * faithful copy of CC's `services/lsp/LSPClient`); * - **TOB** = a WS bridge (the server runs INSIDE a remote sandbox, reached over a WebSocket — service's * `ws-transport`, because `execStream` is output-only so the base seam can't drive a stdio server remotely). */ export interface LspTransport { /** A JSON-RPC request; resolves the `result` (or rejects on a JSON-RPC error / timeout / abort). */ request(method: string, params: unknown, signal?: AbortSignal): Promise<unknown>; /** A JSON-RPC notification (no response) — e.g. `textDocument/didOpen`. */ notify(method: string, params: unknown): void; /** * design/121: subscribe to server→client NOTIFICATIONS (a message with a `method` and no `id` — * `textDocument/publishDiagnostics`, `window/logMessage`, …). Returns an unsubscribe. Optional and * ADDITIVE: a transport without it — or with no subscriber — behaves exactly as before (notifications * dropped). The transport must swallow a throwing handler (a bad consumer must never kill the read loop). */ onNotification?(handler: (method: string, params: unknown) => void): () => void; close(): Promise<void>; /** True once the underlying connection is gone (closed/errored) — the manager evicts + reopens. Optional so * a test mock stays minimal. */ readonly closed?: boolean; } /** Reads a workspace file's CURRENT text for the session's didOpen/didChange re-sync BY NAME — the read of a caller with no * gate (no read face) only; a gated read is the admitted held object's ({@link LspReadAdmission}). TOC = local fs; TOB = * the sandbox ExecutionEnv. */ export type LspReadText = (filePath: string, signal?: AbortSignal) => Promise<string>; /** CC 2.1.220's LSP file cap (10MB — `File too large for LSP analysis`, (pretty220.js:474745, historical; not relocated in 250 — see cc-250 anchors)). Single-sourced * in core: the TOOL-layer check in {@link createLspTool} (the model-visible enforcement, CC-parity) and the * defense-in-depth throw in the engine's `defaultLspReadText` must agree on one number. */ export declare const MAX_LSP_FILE_BYTES: number; /** * Resolves a language server session for a file. Returns `undefined` when no server is available for the * file's language — the tool then degrades gracefully (design/64 §13.1 remote-portability constraint). */ export interface LspServerManager { /** * Resolve a session for `filePath`'s language, or `undefined` to degrade. `env` is the task's * ExecutionEnv (the SAME sandbox the agent runs in) — core passes it because a real in-env language * server must run inside that sandbox, and core already holds the per-task env at mount time. Supplying * it here means a manager can be **stateless** (it doesn't need a sessionId→env registry). It is * optional/last for back-compat: a manager that resolves the env another way (e.g. a sessionId registry * keyed off `executionEnvFactory`) may ignore it. */ sessionFor(filePath: string, signal?: AbortSignal, env?: ExecutionEnv): Promise<LspSession | undefined>; /** * HRD-LSP-14 — OPTIONAL: why the last {@link sessionFor} for this file returned `undefined`, as a phrase * for the model-facing degrade line ("no language server for its language in this environment" is used * when absent). `undefined` ⇒ nothing more specific to say. ADDITIVE: a manager without it behaves * exactly as before. Exists because `undefined` collapses "this language has no server configured", * "the binary is not installed" and "the server is installed and fails to start" into one sentence that * is only true for the first — and the operator can only act on the third if someone says it. */ unavailableReason?(filePath: string, env?: ExecutionEnv): string | undefined; /** design/121: the manager's workspace diagnostics registry, when the deployment's manager supports * pushed diagnostics (NodeLspManager always does). The Runner drains it at turn boundaries to inject * `<new-diagnostics>` + emit the structured `diagnostics` frame. Absent ⇒ no diagnostics lane. */ readonly diagnostics?: import("./lsp-diagnostics.js").LspDiagnosticsRegistry; } export interface LspToolOptions { /** Drop results in files ignored by git (CC parity, design/64 §16/§17). Default: keep all. */ isPathIgnored?: (uri: string) => boolean | Promise<boolean>; /** The task's ExecutionEnv, passed through to {@link LspServerManager.sessionFor} (the agent's sandbox — * where an in-env language server must run). The Runner wires this from the per-task env. */ env?: ExecutionEnv; /** * The read face this mount reads under (needs `env`). PRESENT ⇒ every read the tool causes passes it: the `filePath` * target is resolved against the declared base (the tracked working directory, else the root) and fenced as `Read` * fences its own — containment with the station's one-read admission, the read-deny table, blocked devices; the file * preconditions (exists / a regular file / the size cap) are read from the object that judgment named, held at its key * (the environment's `openBound`), never from the spelling again; the session reads an opened file only from the * object the face admitted and held (an environment without `openBound` reads nothing) and closes one it does not * admit; every location or symbol in a file the deny table reaches is withheld, and the result says how many. ABSENT ⇒ * no read face judged this mount (a library-direct tool with no gate): a relative `filePath` resolves against * `env.cwd`, the preconditions and the session's reads go by name, nothing is fenced and nothing is withheld. */ readFace?: LspReadFace; } /** Build the `lsp` tool over a {@link LspServerManager}. Mounted only when a manager is wired (opt-in). */ export declare function createLspTool(manager: LspServerManager, opts?: LspToolOptions): AgentTool; /** * A git-check-ignore-based path filter for {@link LspToolOptions.isPathIgnored} (CC parity). Best-effort: * if git isn't available or the check fails, nothing is treated as ignored (keep all). Caches per path. */ export declare function gitCheckIgnoreFilter(env: ExecutionEnv, root: string): (uri: string) => Promise<boolean>;