UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

94 lines (93 loc) • 5.77 kB
/** * Bounded rolling-TAIL accumulator for streaming exec output (sema D2) — FIRST-PARTY, vendor-independent. * * A model-driven `yes` / `cat big.bin` over a 120s/600s shell can emit gigabytes; accumulating it unbounded OOMs * a durable worker. We keep only the LAST `maxBytes` — the tail (errors + the exit code are at the end, which is * what the model needs) — dropping the head as it overflows, plus a count of dropped bytes for a visible marker. * * This lives in `src/core/` — NOT `src/vendor/` — on purpose: it is OUR robustness invariant, shared by the * (currently-vendored) NodeExecutionEnv AND a downstream exec adapter (service host/ssh/adb/docker/k8s). * One implementation + ONE model-facing marker text = zero drift, and the public export points at a STABLE path * that survives the planned de-vendoring (when NodeExecutionEnv is naturalized out of `src/vendor/`, nothing rebinds). */ import { StringDecoder } from "node:string_decoder"; export declare const MAX_EXEC_OUTPUT_BYTES: number; /** * Push-based bounded tail: feed it raw output chunks (Buffers); it retains only the last `maxBytes`, dropping the * head incrementally so memory is O(maxBytes) regardless of total output. Byte-precise (the OOM bound is bytes). */ export declare class RollingTailBuffer { private readonly maxBytes; private readonly chunks; private size; private headDropped; private sourceSkipped; constructor(maxBytes?: number); /** Append a chunk; evict head bytes (whole or partial chunks) until within `maxBytes`. */ push(chunk: Buffer): void; /** Account bytes that were dropped BEFORE ever reaching the buffer (e.g. a spool reader skipping * un-retainable backlog at the source). They join `droppedBytes` so `markTruncated` discloses the * TRUE number of missing bytes — recording them here instead of pushing keeps the drop O(1). */ recordSkippedBytes(n: number): void; /** The retained tail decoded as UTF-8, plus how many bytes are missing in total (head evictions + * source skips — the foreground `markTruncated` disclosure keeps the combined figure). */ result(): { text: string; droppedBytes: number; }; /** The retained window's RAW bytes — cursor slicing must happen in the byte domain * (re-encoding a lossily-decoded string inflates every invalid byte to a 3-byte U+FFFD and lands * byte cursors on the wrong offsets). */ bytes(): Buffer; /** Head-eviction count ALONE — the retention window is [headDroppedBytes, pushed-total) * in the accepted-byte domain; source skips must not shift this origin. */ headDroppedBytes(): number; /** Source-skip count alone (see {@link recordSkippedBytes}) — poll disclosure reports * the per-poll increment of this figure instead of silently absorbing it into the window origin. */ sourceSkippedBytes(): number; } /** Per-stream cursor state for incremental (poll-style) consumption of a * {@link RollingTailBuffer}. All counters live in the ACCEPTED-byte domain (bytes actually pushed); * source skips are tracked separately for disclosure. Module-level export (not on the package * surface) so the slice arithmetic is unit-pinnable without an 8MB integration harness. */ export interface StreamCursorState { tail: RollingTailBuffer; /** Consumer position in the accepted-byte domain (bytes already delivered). */ cursorBytes: number; /** Bytes actually pushed into `tail` (excludes source skips — those never had content to deliver). */ acceptedBytes: number; /** `tail.sourceSkippedBytes()` as of the last slice — the delta is disclosed exactly once. */ disclosedSkippedBytes: number; /** Incremental UTF-8 decoder (CC 2.1.220 shape: StringDecoder holds a split multi-byte char across * reads instead of emitting replacement chars at every read boundary). */ decoder: StringDecoder; } /** Fresh cursor state over a new tail. */ export declare function newStreamCursorState(tail?: RollingTailBuffer): StreamCursorState; /** * Slice the undelivered increment out of a rolling tail — in the BYTE domain, decoded * incrementally. Replaces the former decode→re-encode→byte-offset-subarray shape, which mixed two * domains: byte cursors landed inside a re-encoded string whose length differs from the raw stream as * soon as any invalid-UTF-8 byte sits in the window (each inflates to a 3-byte U+FFFD), making every * later poll re-deliver the same trailing garbage forever (a sleeping process "producing" output). * * - Continuity: a multi-byte char split across two slices is held by the decoder and completed on the * next slice — zero replacement chars, zero duplication. * - Discontinuity (`droppedBeforeCursor > 0`): the decoder is rebuilt, so bytes on either side of a * hole are never joined into a fake character. * - `terminal`: flush the decoder (a trailing split char becomes one honest U+FFFD) — without this * the last 1–3 bytes of a finished stream would never surface. * * `droppedBeforeCursor` = head-evicted bytes the cursor never got to + the NEW source-skip increment * since the previous slice (formerly silent: skips shifted the window origin instead, re-delivering * already-served bytes with `truncated` unset). */ export declare function sliceStreamIncrement(s: StreamCursorState, terminal: boolean): { inc: string; droppedBeforeCursor: number; }; /** * Prepend a model-visible marker when `droppedBytes > 0` (only the TAIL was kept). The SINGLE source of the * marker wording so the engine and any downstream adapter show the model the SAME thing. */ export declare function markTruncated(text: string, droppedBytes: number): string;