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