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