@sema-agent/core
Version:
Stateless, task-oriented AI agent core
227 lines (226 loc) • 14.6 kB
TypeScript
import type { AgentTool, 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 {
request(op: LspOperation, params: LspRequestParams, signal?: AbortSignal): Promise<LspResult>;
}
/**
* 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. 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;
}
/** 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>;