@sema-agent/core
Version:
Stateless, task-oriented AI agent core
307 lines (306 loc) • 20.9 kB
TypeScript
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>;