UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

226 lines (225 loc) • 17 kB
import { type AgentTool, type ExecutionEnv } from "../../internal/harness.js"; import { type TaskRegistry } from "../../core/task-registry.js"; import { type CwdRef } from "./fs-shared.js"; import type { ReversibilityVerdict } from "../../core/types.js"; import type { ShellReadAdmission } from "./shell-read-check.js"; /** * The full shell face's SHAPE classifier for the `bash` tool, exposed as a `ToolSpec.reversibilityProbe` * (the "maybe" tier's probe, installed on every full shell). A command is reversible ONLY if every connected * segment is a bare command whose program is on the reversible allowlist (default * {@link bashClassifyDefaultAllow} — curated to commands with NO write/mutation mode under ANY args) and whose * SHAPE the compound classifier accepts (no redirects, substitutions, subshells, escapes or backgrounding; no * blocking stdin read, follow mode or device read; a guarded verb form in its safe spelling) — or a bounded * read-only poll loop of such commands ({@link classifyBoundedReadonlyPollLoop}). EVERYTHING else — a write * command, an egress command, an operator, a path-prefixed or env-assigned command, a backgrounded call, a * non-string arg — is NOT reversible → the gate tightens a surviving allow to an ordinary ask. Fail-closed by * construction: the allowlist is the only path to `reversible: true`. * * WHERE a command reads is not this probe's question. The read boundary — deny rows, containment, symlinks, * patterns, traversals, the working directory a `cd` moves — is the gate's shell READ STATION * (`shell-read-check.ts`), judged on every shell call under every mode, ahead of this probe; a call the * station refused never reaches it, and a call the station asks about is asked about whatever this probe * says. `boundary` supplies only the environment's declared home, so the `$HOME`/`~` spellings fold here the * way the station folds them; nothing else of it is read. A caller-supplied `allow` is honored verbatim, so a * narrowed deployment is never widened by the default superset. */ export declare function bashReversibilityProbe(allow?: readonly string[], boundary?: { readonly homeDir?: string; } | (() => { readonly homeDir?: string; } | undefined)): (args: unknown) => ReversibilityVerdict; /** * The FULL shell's contract id, single-sourced: both shell faces share the wire * name "Bash", so this id is the ONE structural discriminator between the write-capable shell and * the read-only allowlist face (`core.bash_readonly@1`). Consumed by prepare-task's * fullShellReachable roster assertion (its first load-bearing consumer). */ export { FULL_SHELL_CONTRACT_ID } from "../../core/tool-catalog-entries.js"; /** The CC-verbatim exit-1 interpretation for `command`, or undefined when exit 1 means a real error. * Conservative parse: last `;`/`&&`/`||`/newline statement → last `|` pipeline segment → leading * command name (env-assignments skipped, path prefix stripped); `git grep`/`git diff` special-cased * (CC `cLp`). Exported for its unit tests. */ export declare function bashExitOneInterpretation(command: string): string | undefined; export declare function canAutoBackground(command: string, depth?: number): boolean; /** One observation of a running command, handed to {@link runShell}'s caller once per * {@link TOOL_PROGRESS_BOUND}.intervalMs. NO clock: how long the call has been running is the RUN's fact, * not this leg's — the two producers of a progress frame do not start their clocks at the same instant, so * the lane that mints the frame owns the origin. The output trio rides or stays away TOGETHER, and only * when the environment has actually streamed something — see the frame's own absence law. */ export interface RunningShellTick { output?: string; totalLines?: number; totalBytes?: number; } /** * `bash` (effect:write) — a full shell. ⚠️ It runs with `rootCanonical` as the initial cwd but is NOT * sandboxed: a command can `cd` out, read/write/delete any path the process can reach, and use the * network. `rootPath` is a file-tool guard rail, NOT a bash sandbox — real isolation is the * deployment's job (inject a chroot/container `ExecutionEnv`). Every call still goes through the * tool-policy gate, which a multi-tenant deployment MUST wire to constrain it. The cwd * persists across calls: a per-task `cwdRef` starts at `rootCanonical` and is updated * from the shell's final pwd after each command. */ export declare function createBashTool(env: ExecutionEnv, rootCanonical: string, coAuthor?: string | false, cwdRef?: CwdRef, taskOpts?: { taskRegistry?: TaskRegistry; taskOwner?: string; taskScope?: string; /** (mirrors MonitorToolOptions.sessionId): when the task runs INSIDE a session, * a background command registers session-resident (owner = sessionId, sessionScoped flag) — it * survives the run teardown like CC's bg shells and is reaped at the session terminal. Absent ⇒ * run-scoped registration exactly as before (killed-with-receipt at teardown). */ sessionId?: string; /** Completion-notification sink — a finished background command fires ONE * task-notification (priority "next": boundary interrupt, CC LocalShellTask posture). */ onTaskNotification?: (n: import("../../core/task-notification.js").TaskNotificationPayload, opts?: { priority?: "now" | "next" | "later"; }) => void; /** Per-tool-call detach hub — a fired signal adopts the running command as background. */ detachHub?: import("../../core/tool-detach.js").ToolDetachHub; /** * EXPLICIT deployment opt-in: on this command's own * timeout, an eligible foreground command (see `canAutoBackground`'s own docstring — a * DELIBERATELY simplified, narrower-than-CC classifier) is adopted as a background task instead * of being killed. Defaults to `false` (the pre-existing, unconditional kill-on-timeout behavior) * — this must NOT be inferred from `detachHub`/`taskRegistry` merely being present, because those * are wired UNCONDITIONALLY for every task (the ctrl+b manual-detach capability, `defaultTaskRegistry` * is a module-level singleton) — tying auto-eligibility to their mere presence would silently flip * the timeout outcome for every existing caller/test with no opt-in signal at all (caught by * `test/tool-cut-kill.test.ts`'s pre-existing "ordinary lane" self-timeout test: a * `canAutoBackground`-eligible command it never expected to survive its timeout started surviving * it). A deployment that wants the CC-parity behavior sets this explicitly; sema's own default * Runner wiring currently does NOT set it, so this feature ships INERT until a caller opts in. * * IT ARMS TWO TRIGGERS, not one — the name is older than the second. Both are the engine deciding * to adopt a running command instead of ending it, both take the same adoption chain, and both are * gated on the same eligibility read of this command's text (`canAutoBackground`): (1) the command's * OWN timeout fires, and (2) a person's input is waiting for the turn boundary while this command * still runs (see {@link queuedHumanInput}). A deployment that opted in for one has opted in for * both; there is deliberately no second knob, because "may this command be moved to the background * by the engine" is one question and answering it twice is how the two triggers would drift apart. */ autoBackgroundOnTimeout?: boolean; /** * The run's tool-progress sink: one call per liveness tick of a running foreground command (about * once a second), minted into a `tool_progress` frame by whoever wired this. Runner-wired; a * standalone toolkit build leaves it absent, and the execute leg then opens no streaming callbacks * at all — its `env.exec` options are byte-identical to what they were before this seat existed. */ onToolProgress?: (body: import("../../core/runner/contracts.js").ToolProgressBody) => void; /** * Is a PERSON's input waiting for this run's next turn boundary? Read on each liveness tick of a * running foreground command: `true` on an {@link autoBackgroundOnTimeout}-armed command moves that * command into the background (through the SAME detach verb ctrl+b fires) so the message is * delivered this turn instead of after the command. CC 2.1.259 `backgroundedToDeliverMessage`. * * The predicate is the HARNESS's own accepted-but-unserved account and deliberately counts only what * a PERSON sent: an engine-authored reminder is not somebody waiting, and stepping a build aside for * one would fire on nearly every long command. Absent ⇒ nothing is ever auto-backgrounded for a * message (the pre-seat behavior), which is also what a standalone toolkit build gets. */ queuedHumanInput?: () => boolean; /** * Mirrors {@link import("../../core/types.js").TaskSpec.oneShot}: this run has no later * turn for an async background notification to land in (a headless `-p` process exits once the * turn ends). Branches the background-launch receipt's guidance toward an active block-wait * instruction instead of "you will be notified — do not poll", the same failure form * `run_workflow`'s launch note was fixed for (a model that follows "end your turn" advice in a * one-shot process loses the result — the process exits with nothing left alive to receive it). */ oneShot?: boolean; /** * The EXTRA containment roots of the structured file tools (additionalDirectories, * canonical) — combined with `rootCanonical` and consulted ONLY for the post-`cd` disclosure * note (a committed cwd outside every root gets one trailer line saying the structured tools' * relative paths now resolve there and may be refused). Bash itself stays deliberately * unconfined; passing this never gates anything. */ additionalRoots?: readonly string[]; /** * The gate's shell read station's pre-spawn ADMISSION (`ShellReadAdmission`, shell-read-check.ts): what * this leg runs is what the station judged before approval, and the admission re-establishes the SAME * candidates right before the spawn — a symlink re-pointed or a pattern re-matched while the approver * was thinking refuses the call instead of running it. It parses nothing and judges no word the * station did not. Absent ⇒ no station judged this mount (a library-direct toolkit with no gate): * nothing is re-established, and the full shell runs unconfined as it always did. */ shellReadAdmission?: ShellReadAdmission; /** Override of the `timeout` parameter's DEFAULT budget, in ms — see * {@link import("./index.js").HandsToolkitOptions.bashDefaultTimeoutMs} for the resolution * contract (options over env over constant; invalid values discarded). */ bashDefaultTimeoutMs?: number; /** Override of the `timeout` parameter's engine CEILING, in ms — only-widen * vs the resolved default; see {@link import("./index.js").HandsToolkitOptions.bashMaxTimeoutMs}. */ bashMaxTimeoutMs?: number; /** Structured sink for this leg's timeout-cap discard announcements — consumed by * {@link import("./fs-shared.js").resolveBashTimeoutCaps} at mount (see * {@link import("./index.js").HandsToolkitOptions.onNotice} for the replace-vs-warn contract). */ onNotice?: (n: import("../../core/types.js").EngineNotice) => void; /** * Whether the Monitor tool is on THIS run's roster (the Runner's own mount predicate is * `backgroundTaskToolsActive`; it, not this tool, mounts Monitor). Read by the SR-2 gh rate-limit * hint, whose closing clause teaches the Monitor tool: `false` ⇒ the clause is dropped (the sleep * advice stays), absent/`true` ⇒ historic full wording (a standalone toolkit consumer may well have * mounted Monitor itself — the omitted default must not silently rewrite its hint). */ monitorToolActive?: boolean; /** The session's reminder provenance mark: stamped on the gh rate-limit * hint's open tag by the mint home (see {@link import("./index.js").HandsToolkitOptions.reminderMark}). * Absent ⇒ historic bare tag. */ reminderMark?: string; /** The task lane of THE retain predicate (`TaskSpec.retainBackgroundProcesses` of the run this mount * serves; see {@link import("./index.js").HandsToolkitOptions.retainBackgroundProcesses}). Read by the * lifetime sentence (description + receipts) and stamped onto every background row this tool registers * (passing it — true or false — states the lane on the row). Absent ⇒ the sentence reads not-retained * and the rows leave the lane to the owning run's teardown. */ retainBackgroundProcesses?: boolean; }): AgentTool; /** * `bash_readonly` (effect:read) — a restricted shell for the verifier read-only boundary: a single * allowlisted, bare command with no shell operators. effect:read lets it survive the verifier's * read-only filter, but it is NOT a policy circumvention — it still goes through the tool-policy gate, * because a read can still be a side channel (dumping a secret file). The allowlist is a coarse pre-filter * over the command's SHAPE; WHERE it reads is the gate's shell read station's judgment (`shell-read-check.ts`, * the `readonly` seat), which REFUSES a deny-listed read, an out-of-root read and a read it cannot establish * — this face has no approval channel to degrade to, so every finding is a refusal, before anything runs. * The execute leg judges nothing: it admits what the station established (`shellReadAdmission` re-establishes * the same candidates right before the spawn) and runs the command. */ export declare function createBashReadonlyTool(env: ExecutionEnv, rootCanonical: string, allow: ReadonlySet<string>, opts?: { /** Same timeout-cap overrides the full `bash` leg takes — see * {@link import("./index.js").HandsToolkitOptions.bashDefaultTimeoutMs} / * {@link import("./index.js").HandsToolkitOptions.bashMaxTimeoutMs} — both foreground legs share * runShell's clamp, so both must share the configurable resolution. */ bashDefaultTimeoutMs?: number; bashMaxTimeoutMs?: number; /** Structured sink for the timeout-cap discard announcements — same seat the full `bash` leg * takes (both foreground legs share the resolver, so both must share the sink). */ onNotice?: (n: import("../../core/types.js").EngineNotice) => void; /** The gate's shell read station's pre-spawn ADMISSION (`ShellReadAdmission`, shell-read-check.ts): what * this leg runs is what the station judged, re-established right before the spawn. Absent ⇒ no station * judged this mount (a library-direct toolkit with no gate): this face then confines NOTHING — its * containment IS the station, and a consumer mounting it without one must run `runToolGate` with the * station's `shellReadCheck` (the Runner does). */ shellReadAdmission?: ShellReadAdmission; /** The run's tool-progress sink — the SAME seat the full shell leg takes, for the same reason its * two neighbours above are shared: both foreground legs run through `runShell`, so a long command * must breathe on both or a person watching one of them is told nothing. This face has no detach * chain, so it ticks and never auto-backgrounds. Absent ⇒ no streaming callbacks, byte-unchanged. */ onToolProgress?: (body: import("../../core/runner/contracts.js").ToolProgressBody) => void; }): AgentTool; /** * `TaskOutput` — read a background shell's NEW output * since the last call (cursor), by task_id. Optional legacy `filter` regex is applied BEFORE the per-poll truncation so a watched line survives even when it * falls in a high-throughput middle window. Untrusted process output is fenced (delimitUntrusted) — observe-only, * never re-fed as instructions. */ export declare function createEnvTaskOutputTool(env: ExecutionEnv): AgentTool; /** `TaskStop` — terminate a background shell by task_id. * * This band kills ENV-DIRECT (it mounts precisely when the toolkit has * no registry — the createHandsToolkit ternary routes registry deployments to createTaskStopTool), so a * row for the SAME shell in the process-local {@link defaultTaskRegistry} (a Runner-mounted run sharing * this env) used to settle via the watcher's no-claimant floor as stoppedBy:"system". The tool now marks * the initiation site ("parent") on `registry` (default: the process-local {@link defaultTaskRegistry}) * BEFORE the kill — attribution only; the kill path and the model-facing receipt text are unchanged. */ export declare function createEnvTaskStopTool(env: ExecutionEnv, registry?: TaskRegistry): AgentTool;