UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

271 lines (270 loc) • 22.7 kB
import type { AgentTool, ExecutionEnv } from "../../internal/harness-types.js"; import { type BeforeWriteHook, type FileEditedHook, type TrackFileEditHook } from "../../core/types.js"; import { type TaskRegistry } from "../../core/task-registry.js"; import { type ReadFileState } from "./safety.js"; import type { PdfModelCapabilities } from "./pdf.js"; export * from "./fs-shared.js"; export * from "./fs-pdf.js"; export * from "./fs-read.js"; export * from "./fs-write.js"; export * from "./fs-search-tools.js"; export * from "./bash-readonly-classifier.js"; export * from "./fs-bash.js"; export * from "./read-deny.js"; export { readDenyHomesOf } from "./read-deny-home.js"; import type { ShellReadAdmission } from "./shell-read-check.js"; export { BASH_CLASSIFY_EXTRA_ALLOW, bashClassifyDefaultAllow } from "./bash-readonly-classifier.js"; import { type ReadDenyEntry } from "./read-deny.js"; import { type ContainmentFace } from "./containment-face.js"; export * from "./containment-face.js"; import { type CwdRef, type ReadImageDownsamplerOption } from "./fs-shared.js"; /** Options for {@link createHandsToolkit}. */ export interface HandsToolkitOptions { /** CC --add-dir parity: extra allowed containment roots (canonical). Widens file-tool containment * only; the primary root keeps the cwd/base role. The full `bash` (effect:write) is NOT constrained * by these — it is deliberately unsandboxed and only uses them for its post-`cd` disclosure note. * `bash_readonly` IS constrained by them (that face refuses a read outside the roots), so widening * here widens what the read-only band may read. */ additionalRoots?: readonly string[]; /** The read-boundary whitelist: extra allowed containment roots for the READ faces only — * read_file/grep/glob/repo_map and `bash_readonly` admit them; edit_file/write_file/notebook_edit * and the full `bash`'s disclosure roots deliberately do NOT (widening reads must never silently * widen writes). Read per call, never copied: a caller that grows the array it passed (the Runner's * in-flight read-root grant) widens the structured read tools for their later calls. Caveat, the same tradeoff `additionalRoots` states: * `bash_readonly`'s COMMAND allowlist is a deployment declaration — a * deployment that puts a mutating command into `bashReadonlyAllow` has declared it read-safe, * and that declaration applies inside every root, these included. Keep the allowlist genuinely * non-mutating if these roots must stay read-only through the shell face too. */ additionalReadRoots?: readonly string[]; /** Mount a shell tool. Pass false for an env without a real shell (e.g. `StubExecutionEnv`) so a bash * tool that can only error never reaches the model. Default false. */ includeShell?: boolean; /** Verifier read-only boundary: mount ONLY effect:read hand tools — read_file/grep/glob * (+ `bash_readonly` when `includeShell`). Drops edit_file/write_file and the full `bash`. Default false. */ readOnly?: boolean; /** Override the `bash_readonly` command allowlist (default {@link BASH_READONLY_DEFAULT_ALLOW}). */ bashReadonlyAllow?: readonly string[]; /** The gate's shell read station's pre-spawn ADMISSION (`ShellReadAdmission`, shell-read-check.ts), handed to * BOTH shell faces this band mounts: what a shell tool runs is what the station judged before approval, and * the admission re-establishes the same candidates right before the spawn. The station itself — ONE parse of * the command, its candidates established on the filesystem, judged by the read deny seat and the read * roots — is the gate's (`ToolGateInput.shellReadCheck`, built by the Runner's hands phase for every shell * face and every doctrine); this band judges nothing on its own. * * ABSENT ⇒ no station judged this mount (a library-direct toolkit with no gate): the full shell runs * unconfined as it always did, and `bash_readonly` confines NOTHING — its containment IS the station, so a * consumer mounting the read-only band outside the Runner must run `runToolGate` with a station of its own * (`createShellReadStation`). */ shellReadAdmission?: ShellReadAdmission; /** The gate's file read station's ADMISSION (`FileReadStation.admitted`, file-read-check.ts), handed to Read / Grep / * Glob: an out-of-root target the gate put to a question and then let through is read once, on the canonical key the * station judged. ABSENT ⇒ no station (a library-direct toolkit): the tools refuse every out-of-root target. */ readAdmission?: (toolCallId: string | undefined, canonicalKey: string) => boolean; /** Commit-message attribution trailer for the `bash` git protocol. Default NONE — attribution is a * deployment identity asset, so the branded deployment sets it; pass `"Name <email>"` to advertise * one, `false` (≡ unset) for no trailer section. */ commitCoAuthor?: string | false; /** The caller's own {@link CwdRef} to track this task's logical cwd, instead of one * created internally. The Runner passes its own so it can OBSERVE `cd` moves (`cwdRef.current` changes) * and emit a `workspace_changed` event. Ignored in `readOnly` mode (no `cd`). Default: a fresh ref at root. */ cwdRef?: CwdRef; /** Optional process-local registry for unified task_id (`b*`) handles. */ taskRegistry?: TaskRegistry; /** Runner-owned owner/scope fallback used when a tool execute context is unavailable in tests. */ taskOwner?: string; taskScope?: string; /** See createBashTool's taskOpts.sessionId: session-resident background commands. */ sessionId?: string; /** The task lane of THE retain predicate (`retainsBackgroundProcesses`): the serving run's * `TaskSpec.retainBackgroundProcesses`. The Runner fills it from the spec of the run it builds this band * for; a direct mount passes its own declaration. Read by Bash's lifetime sentence (description + * receipts) and stamped onto every background row Bash registers (stated). Absent ⇒ not stated: the rows * leave the lane to the owning run's teardown. */ retainBackgroundProcesses?: boolean; /** Mount TaskOutput/TaskStop directly from this band. Runner sets false and mounts the unified dispatcher once. */ mountBackgroundTaskTools?: boolean; /** Completion-notification sink for finished background commands (threaded to createBashTool's * onTaskNotification; the Runner wires the run-local injection lane here). */ taskNotification?: (n: import("../../core/task-notification.js").TaskNotificationPayload, opts?: { priority?: "now" | "next" | "later"; }) => void; /** The run-local per-tool-call detach hub (mid-flight ctrl+b → adopt as background). */ detachHub?: import("../../core/tool-detach.js").ToolDetachHub; /** The Bash `timeout` parameter's DEFAULT budget, in ms — applied when the model omits `timeout`. * Resolution (options over env over constant — the standing sema discipline: deployments configure * the tool, not the process): this field ?? env `BASH_DEFAULT_TIMEOUT_MS` ?? 120000; invalid * (non-finite / <= 0) values are discarded at every leg. See * {@link import("./fs-shared.js").resolveBashTimeoutCaps} — the SAME resolver renders the tool * description, so the advertised numbers always match the enforced ones. Absent ⇒ 120s. Applies to * both shell legs (`bash` and `bash_readonly`). */ bashDefaultTimeoutMs?: number; /** The Bash `timeout` parameter's engine CEILING, in ms — requests above it are clamped (the * requested/capped disclosure names THIS value). Resolution: (this field ?? env * `BASH_MAX_TIMEOUT_MS` ?? 600000), then floored at the resolved default — CC's own only-widen * semantic: a ceiling below the default is a narrowing intent = invalid, ignored in favor of the * default. Absent ⇒ 600s. */ bashMaxTimeoutMs?: number; /** Structured sink for the timeout-cap discard announcements both shell legs emit at mount (see * {@link import("../../core/types.js").EngineNotice} / `RunnerDeps.onNotice`). Wired, it REPLACES * their `console.warn` line (a forwarding host must not show every fact twice); absent, the * console line prints verbatim (last-resort loudness). Threaded per toolkit build — never a * global. */ onNotice?: (n: import("../../core/types.js").EngineNotice) => void; /** See createBashTool's taskOpts.oneShot / createTaskOutputTool's TaskToolOptions.oneShot for the * full contract: this run has no later turn for an async background notification to land in. * Threaded to both the Bash background-launch receipt and (when this band mounts TaskOutput itself, * `mountBackgroundTaskTools: true`) the TaskOutput poll receipt. Absent ⇒ the unbranched wording. */ oneShot?: boolean; /** CC `Zry`/`WZi.#m` parity: see createBashTool's taskOpts field of the same name for the full * contract. Threaded from {@link HandsBandOptions.autoBackgroundOnTimeout} (a deployment-level * opt-in) — NOT from `detachHub`/`taskRegistry` merely being present, which are wired unconditionally * by the Runner for every task and so cannot double as an opt-in signal. Default undefined ⇒ `false` * downstream. */ autoBackgroundOnTimeout?: boolean; /** The run's tool-progress sink — see createBashTool's taskOpts field of the same name. Runner-wired * (a late-bound cell behind the closure), absent for a standalone toolkit build ⇒ the shell's execute * leg opens no streaming callbacks at all and its `env.exec` options are byte-identical to before. */ onToolProgress?: (body: import("../../core/runner/contracts.js").ToolProgressBody) => void; /** Is a PERSON's input waiting for this run's next turn boundary — see createBashTool's taskOpts field * of the same name. Runner-wired (reads the harness's own accepted-but-unserved account); absent ⇒ the * shell never auto-backgrounds a running command, exactly as before. */ queuedHumanInput?: () => boolean; /** Read-image downsampler seam ({@link ReadImageDownsamplerOption}). Default undefined = auto-detect * the optional sharp dependency once per process; `false` disables it (deterministic no-sharp behavior). */ readImageDownsampler?: ReadImageDownsamplerOption; /** PDF degradation chain: the SERVING model's PDF capability profile (prepare-task derives it via * {@link import("./fs-pdf.js").pdfModelCapabilitiesOf}). A model without native document input gets pdftotext text extraction → * rendered page images (vision) → an honest placeholder, instead of a brain-level placeholder. Default * undefined = fully capable (native document block). */ pdfModelCapabilities?: PdfModelCapabilities; /** Write-time content gate for Write/Edit/NotebookEdit (see {@link BeforeWriteHook}). The Runner * wires the MemoryEngine's memory-domain scan here; absent ⇒ byte-identical behavior. */ beforeWrite?: BeforeWriteHook; /** First-touch file-history hook for Write/Edit/NotebookEdit (see * {@link TrackFileEditHook}): called after the content gates pass, immediately before the final * env write, so the file's pre-edit state is durably recorded before it changes. The Runner * wires it to `FileHistoryStore.trackEdit`; absent ⇒ byte-identical behavior (no history). */ trackFileEdit?: TrackFileEditHook; /** The mutation lane's LANDED observation seat — fired once per Write/Edit/NotebookEdit call whose * bytes reached disk (never for a refused/failed one, never for a Bash-authored change). Wired by * the Runner to the per-run accumulator behind `TaskResult.editedFiles`; absent ⇒ nothing observes. * Purely additive: it cannot refuse a write, and a fault in it is contained in both shapes (a * synchronous throw and a rejected promise), neither of them awaited. */ onFileEdited?: FileEditedHook; /** See createBashTool's taskOpts field of the same name: whether the Monitor tool is on this run's * roster (the Runner mounts Monitor, this band never does). `false` drops the gh rate-limit hint's * Monitor clause; absent ⇒ the full wording. */ monitorToolActive?: boolean; /** ADDITIONS to the built-in sensitive-path READ deny set * ({@link import("./read-deny.js").READ_FACE_DEFAULT_DENY_ENTRIES}), judged by the structured read * faces (Read/Grep/Glob/RepoMap and their traversals) in BOTH containment modes. Add-only: * additions union with the active built-ins, `[]` ≡ absent (union identity), and additions can * never remove a built-in — the one removal channel is the deployment's built-in configuration * ({@link readDenyBuiltinTiers} / {@link readDenyBuiltinExclude}). Bad entry shapes throw at wiring * time. The write faces are untouched (their guard is createSensitivePathPolicy). */ readDenyPatterns?: readonly ReadDenyEntry[]; /** The deployment's built-in-table TIER selection ({@link import("./read-deny.js").ReadDenyBuiltinConfig} * `tiers` key) — the switch for the built-in table: * EXACTLY the listed tiers of {@link import("./read-deny.js").READ_FACE_BUILTIN_DENY_TABLE} * are active (`[]` = none — explicit and legal); absent = the default selection * ({@link import("./read-deny.js").READ_DENY_DEFAULT_TIERS}: `credentials` and the home-anchored `agent-config`). * Composes with {@link readDenyBuiltinExclude} (tier selection first, then row removal) and never * touches the additions seats. Unknown tier names throw at wiring time. Deployment seat ONLY — * deliberately not a TaskSpec key, so no task can widen below its deployment. */ readDenyBuiltinTiers?: readonly string[]; /** Per-row removal from the built-in table by STABLE NAME (= the row's canonical pattern text, e.g. * `".ssh"`, `".config/gcloud"`), applied after {@link readDenyBuiltinTiers} selection. Naming a row * of an inactive tier is a satisfied intent (no-op); a name matching NO table row throws at wiring * time (never silently ignored). Deployment seat ONLY, same clamp argument as the tiers key. */ readDenyBuiltinExclude?: readonly string[]; /** The spellings of the execution environment's home a home-anchored deny row (`~/…`) is placed under * ({@link import("./read-deny-home.js").readDenyHomesOf}: the declared home and the account home, each with its canonical * spelling). Absolute path strings only — any other shape throws at wiring time, naming the seat. The Runner passes the * spellings its own judge was compiled with. A list given here is the band's home set as is: the account database is * not asked and no home is added (each listed home is anchored at its spelling and, where the environment answers one, * its canonical spelling), and the band's home views (a path spelled through a firmlink or a bind mount of a home, * judged at the home) read this list and the environment's declared home; an entry that is not the declared home is probed once, * under a short bound of its own, and a probe of it that fails costs that entry alone. The public shell judgment takes the * same list as {@link import("./shell-read-check.js").ShellReadJudgeInput.readDenyHomes}. */ readDenyHomes?: readonly string[]; /** Where each home-anchored row's literal head leads beyond the home's spellings * ({@link import("./read-deny-home.js").readDenyHomeLinksOf}, keyed by the row's pattern) — the Runner passes the * locations its own judge was compiled with, so the band and the gate judge one placement. * Unless a mount carries both, the band resolves the placement itself the way the Runner does (the homes it carries * are its home set — the account is not asked and no home is added; * {@link import("./read-deny-home.js").placeReadDenyAtHome}), and its read tools (Read / Grep / Glob / RepoMap) wait * for that placement before they judge anything. A placement that fails leaves the home-anchored rows held anywhere. * Carrying both is the engine's own handoff of ONE placement — its home views travel with it — so * `readDenyHomes` must then be the very list that placement returned: any other list (a copy) holds the home-anchored * rows at every location and is named on `console.warn`. */ readDenyHomeLinks?: ReadonlyMap<string, readonly string[]>; /** The permission-rule rows of the deny table ({@link import("./read-deny.js").PathRuleDenyRow}, `ToolPolicy.pathDenyRules`): * the read-face rows join the structured read faces' judge beside the segment rows. The Runner passes the leg's own. */ pathDenyRules?: readonly import("./read-deny.js").PathRuleDenyRow[]; /** The READ-face containment state for the structured read faces * ({@link import("./containment-face.js").ContainmentFace}; resolved through the SAME * {@link import("./containment-face.js").resolveContainmentFace} order prepare-task uses). Absent ⇒ the * resolution order's default = "roots" (the engine never opens implicitly). "open" skips ONLY the * roots containment judgment — the deny set, the UNC out-of-set refusal and the special-file type * gates run in both faces. Beside `readOnly: true` the mount wins: this seat is deps-shaped (a * deployment default, not a per-call assertion), so its "open" CLAMPS to "roots" and the clamp is * ANNOUNCED (`config.read_face_deployment_clamped`, through `onNotice` / `console.warn`) — the loud * refusal belongs to the TASK seat (`TaskSpec.readFace`), the one genuine per-call contradiction. * Never affects the write faces — {@link writeFace} is a separate seat with its own default. */ readFace?: ContainmentFace; /** The WRITE-face containment state for the structured write faces (Edit / Write / NotebookEdit) * ({@link import("./containment-face.js").ContainmentFace}; resolved through the SAME * {@link import("./containment-face.js").resolveContainmentFace} order — one body, one default, * per seat). Absent ⇒ "roots" (today's behavior, byte-identical). "open" skips ONLY the roots * containment judgment on the write band — the write-side sensitive-path policy, the blocked-device * gate, the UNC out-of-set refusal and the read-before-edit requirement all still run, and the full * `bash` face is unaffected either way (it was never fenced). Beside `readOnly: true` the mount * wins the same way the read seat's does (that mount carries no write faces at all), announced as * `config.write_face_deployment_clamped`. Never affects the read faces. */ writeFace?: ContainmentFace; /** A runner's LIVE write face — its resolution, which reads the permission mode's face on every call. Present ⇒ * the write tools read it per call and {@link writeFace} is not consulted (the runner resolved and screened the * seats itself); absent ⇒ the toolkit's own resolution of {@link writeFace}, fixed at creation. */ writeFaceLive?: () => ContainmentFace; /** The session's reminder provenance mark: the band's engine-minted * `<system-reminder>` open tags (the Read dedup/offset/empty reminders and the gh * rate-limit hint) carry it as `mark="…"`, matching the system-prompt declaration the Runner * renders for the same session. Threaded by prepare-task; a library-direct mount that omits it * keeps bare tags — with no declaration in its prompt, a bare tag is the honest form. The tag is * rendered by the mint home (core/reminder-mint.ts) — the content BODY next to it is * byte-untouched. */ reminderMark?: string; /** The per-run trailer/defuse trigger counters (observation seat): * the Read text/notebook/PDF-text disclosure trailers bump `read.*` / `notebook.*` / `pdf.*` * keys here. The form suffixes are enumerated once, on the consumer-facing contract * (`TaskResult.stats.mechanisms.reminderDisclosures` in core/types.ts) — this map is the same * open record and is not a second, narrower registry of them. Threaded by prepare-task, which folds the * non-zero result into `TaskResult.stats.mechanisms.reminderDisclosures`; a library-direct * mount may pass its own object or omit it (counting off — and the whole disclosure pipeline * is off anyway when `reminderMark` is absent). */ reminderDisclosureCounts?: import("../../core/reminder-disclosure.js").ReminderDisclosureCounts; } /** Whether the band's WRITE half (Edit / Write / NotebookEdit, and the full `bash`) mounts for these options — the ONE * predicate {@link createHandsToolkit} branches on and prepare-task plans by (the memory phase must know, before the * band exists, whether the instruction-taught write channel will be there). `readOnly` is the verifier boundary. */ export declare function handsWriteHalfMounts(opts: Pick<HandsToolkitOptions, "readOnly">): boolean; /** The engine's background-output read exemption over one toolkit's seats: the exact registered output file of a * background command, visible to the caller's own owner / scope / session, on THIS execution env — the file the * launch receipt tells the model to Read. Shared by the Read tool's fence and the file read station, so the station * puts no question where the fence would admit. */ export declare function backgroundOutputReadExemption(registry: TaskRegistry | undefined, seat: { readonly taskOwner?: string; readonly taskScope?: string; readonly sessionId?: string; }, env: ExecutionEnv): ((canonicalKey: string, c?: { taskId?: string; principal?: string; }) => boolean) | undefined; /** * Build the per-task hand tool band over an injected env + fresh per-task read state. * `rootCanonical` is the already-canonicalized containment root (prepare-task resolves it once). Normal * mode mounts read/edit/write/grep/glob (+`bash` when `includeShell`); `readOnly` mode mounts only the * effect:read tools read/grep/glob (+`bash_readonly` when `includeShell`) — the verifier boundary. Subset * selection beyond that is the caller's job via the tool-filter. */ export declare function createHandsToolkit(env: ExecutionEnv, readFileState: ReadFileState, rootCanonical: string, opts?: HandsToolkitOptions): AgentTool[];