diff --git a/AGENTS.md b/AGENTS.md index 811edf1..a9dd6ac 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,9 +17,7 @@ Two languages, one contract between them: - **Python side** (`tartarus/`): reads `/manifest.json` and runs. The harness makes **no `nix` calls at runtime**; the manifest is the only bridge. Entry point is `main.py` → `tartarus.cli:main`. -- `PLAN.md` is the authoritative design doc (architecture, manifest contract, - security invariants, glossary). README is the user-facing quick start. If - prose and code disagree, code wins. +- README is the user-facing quick start. If prose and code disagree, code wins. ## Developer commands @@ -66,7 +64,7 @@ Agent selector: `.#` as the first positional arg, or `TARTARUS_AGENT`. ## Sandbox / security invariants (do not break these) -From `tartarus/jail.py` and `PLAN.md §8`: +From `tartarus/jail.py`: - Every brokered tool call runs under `bwrap --unshare-all` with only the declared closure's store paths bound **read-only** — never the whole diff --git a/README.md b/README.md index 974b6c7..99f5f3d 100644 --- a/README.md +++ b/README.md @@ -174,6 +174,12 @@ example agent is in `agent.nix`. sampling = { temperature = 0.6; }; }; + context = { + maxChars = 120000; + recentTurns = 20; + autoCompact = false; + }; + capabilities.read_package_json = { description = "Read package.json from the work tree."; policy = "auto"; @@ -217,13 +223,19 @@ not collide. It defaults to `agent` otherwise. coding set: `read`, `list`, `glob`, `grep`, `write`, `edit`, `bash`, and `webFetch`. Single-capability modules are available if you want to assemble a narrower agent. +The optional `context` block declares the agent's context policy: `maxChars` (soft +ceiling on effective context size), `recentTurns` (recent user turns always kept +verbatim), and `autoCompact` (deterministically compact at a turn boundary once over +`maxChars`; defaults to off so compaction stays an explicit, visible action). Any +field left unset falls back to an env override or the built-in default. + ## Configuration -Backend settings can come from the environment or from the agent's `model` block. -Precedence is per field: +Backend and context settings can come from the environment or from the agent's +`model` / `context` blocks. Precedence is per field: ```text -explicit env var > agent model field > built-in default +explicit env var > agent model/context field > built-in default ``` API keys and extra request headers are environment-only. @@ -243,6 +255,9 @@ API keys and extra request headers are environment-only. | `TARTARUS_HEADLESS` | `false` | Make `ask-*` policies fail closed | | `TARTARUS_AUDIT_PATH` | `/.tartarus/audit.jsonl` | Audit log path | | `TARTARUS_SESSIONS_DIR` | `/.tartarus/sessions` | Session transcript directory | +| `TARTARUS_CONTEXT_DIR` | `/.tartarus/context` | Per-session context ledger directory | +| `TARTARUS_CONTEXT_MAX_CHARS` | `120000` | Soft ceiling on effective context size, in characters | +| `TARTARUS_CONTEXT_RECENT_TURNS` | `20` | Recent user turns always kept verbatim | | `TARTARUS_OUTPUT_TRUNCATE` | `10000` | Tool output truncation limit in characters | To use a local OpenAI-compatible server: @@ -280,7 +295,6 @@ decision, grant delta, command, exit code, output length, and errors. | `tartarus/` | Python harness: config, bundle loading, provider, loop, broker, jail | | `templates/default/` | `nix flake init -t` starter for a new agents flake | | `tests/` | Unit and integration tests | -| `PLAN.md` | Architecture, contract details, and implementation history | ## Development diff --git a/flake.nix b/flake.nix index 85c3a0c..556216b 100644 --- a/flake.nix +++ b/flake.nix @@ -82,6 +82,22 @@ profileAgent = evalModuleAgent [ modules.coding ]; + # An agent declaring a context policy: only the set fields are emitted, + # so null fields stay absent for env/default resolution in the harness. + contextAgent = evalModuleAgent [ + { + context = { + maxChars = 5000; + recentTurns = 3; + autoCompact = true; + }; + capabilities.read_package_json = { + description = "Read package.json from the work tree."; + policy = "auto"; + runner = "cat package.json"; + }; + } + ]; inlineAgent = evalModuleAgent [ { capabilities.read_package_json = { @@ -113,6 +129,7 @@ runner = "true"; grants = "bad"; }; + negative-context-max-chars = evalModuleAgent [ { context.maxChars = -1; } ]; }; # Layer 2: capabilityAssertions — each case is otherwise type-valid, so @@ -212,6 +229,15 @@ multipleAgents.default.config.build.manifest.capabilities ? read && multipleAgents.research.config.build.manifest.capabilities ? web_fetch ) "multiple agents under agents. must compile" + && lib.assertMsg ( + contextAgent.config.build.manifest.context == { + maxChars = 5000; + recentTurns = 3; + autoCompact = true; + } + && defaultManifest ? capabilities + && !(defaultManifest ? context) + ) "context policy must be emitted only when declared, with set fields only" && lib.assertMsg ( (builtins.sort builtins.lessThan defaultToolNames) == (builtins.sort builtins.lessThan expectedDefaultTools) diff --git a/lib/agents.nix b/lib/agents.nix index 78dc939..fee572e 100644 --- a/lib/agents.nix +++ b/lib/agents.nix @@ -93,6 +93,30 @@ let }; }; + contextType = types.submodule { + options = { + maxChars = lib.mkOption { + type = types.nullOr (types.addCheck types.int (n: n >= 0)); + default = null; + description = "Soft ceiling on effective context size, in characters."; + }; + recentTurns = lib.mkOption { + type = types.nullOr (types.addCheck types.int (n: n >= 0)); + default = null; + description = "Number of recent user turns always kept verbatim."; + }; + autoCompact = lib.mkOption { + type = types.nullOr types.bool; + default = null; + description = '' + When true, compact deterministically at a turn boundary once the + effective context exceeds maxChars. Defaults to off so compaction + stays an explicit, visible action. + ''; + }; + }; + }; + paramType = types.submodule { options = { type = lib.mkOption { @@ -306,6 +330,11 @@ let type = types.nullOr modelType; default = null; }; + context = lib.mkOption { + type = types.nullOr contextType; + default = null; + description = "Optional context policy; null fields fall back to harness defaults."; + }; shell = { packages = lib.mkOption { type = types.listOf types.package; @@ -400,6 +429,11 @@ let } // lib.optionalAttrs (config.systemPrompt != null) { inherit (config) systemPrompt; } // lib.optionalAttrs (config.model != null) { inherit (config) model; } + // lib.optionalAttrs (config.context != null) { + # Emit only the fields the agent set; null fields stay absent so the + # harness applies env override or built-in default (resolve_context). + context = lib.filterAttrs (_: value: value != null) config.context; + } // lib.optionalAttrs (hookDrv != null) { shellHook = "${hookDrv}"; }; # Mirrored in tartarus/manifest.py (_RESERVED_SHELL_ENV_NAMES); the two # must stay in sync. Drift is silent except for the Python pin test diff --git a/tartarus/agent_loop.py b/tartarus/agent_loop.py index ac8cfec..d34d735 100644 --- a/tartarus/agent_loop.py +++ b/tartarus/agent_loop.py @@ -1,4 +1,4 @@ -"""The provider-agnostic agent loop (PLAN.md §6.4). +"""The provider-agnostic agent loop. The loop never branches on which provider is configured: it streams a turn from the provider, forwards text to the caller as it arrives, brokers any tool calls, @@ -159,3 +159,6 @@ class AgentLoop: def _tools(self) -> list[dict]: return [*self._manifest.tools, *CONTEXT_TOOLS] + + def auto_compact(self, messages: list[dict]) -> None: + self._context_manager.maybe_compact(messages) diff --git a/tartarus/background.py b/tartarus/background.py index dcc98cb..9e3f6c8 100644 --- a/tartarus/background.py +++ b/tartarus/background.py @@ -1,4 +1,4 @@ -"""BackgroundRegistry: track detached capability runs (PLAN.md §6.9). +"""BackgroundRegistry: track detached capability runs. A capability declared `kind = "background"` is launched detached by the jail and handed here. The registry assigns it a short id (`bg-1`, `bg-2`, …), tails its diff --git a/tartarus/broker.py b/tartarus/broker.py index d83fad4..eb3c7db 100644 --- a/tartarus/broker.py +++ b/tartarus/broker.py @@ -1,4 +1,4 @@ -"""The Broker: resolve → validate → policy → build jail → exec → result (§6.5). +"""The Broker: resolve → validate → policy → build jail → exec → result. Each tool call is validated, gated by the PolicyEngine (which may prompt the human), then run inside a bwrap jail via the injected JailBuilder. Argument @@ -62,7 +62,7 @@ def interpolate(runner: str, arguments: dict) -> str: """Fill a runner template's {placeholders}, shell-escaping every value. Every model-supplied value is passed through shlex.quote so untrusted text can - never break out of its argument position (PLAN.md §8.5). + never break out of its argument position. """ safe_values = defaultdict( lambda: shlex.quote(""), diff --git a/tartarus/bundle.py b/tartarus/bundle.py index 42a7a4d..1bd029d 100644 --- a/tartarus/bundle.py +++ b/tartarus/bundle.py @@ -1,4 +1,4 @@ -"""Load an agent from a realized bundle (PLAN.md §14). +"""Load an agent from a realized bundle. A bundle is one store derivation whose runtime closure is the whole agent: `manifest.json` plus every store path it references (package bins, per-capability diff --git a/tartarus/cli.py b/tartarus/cli.py index 5a7867c..49252b5 100644 --- a/tartarus/cli.py +++ b/tartarus/cli.py @@ -24,6 +24,7 @@ from tartarus.config import ( ResolvedRuntime, context_dir_from_env, load_config, + resolve_context, resolve_runtime, session_dir_from_env, ) @@ -170,21 +171,37 @@ def _persist( store: SessionStore | None, ledger: ContextLedger | None, messages: list[dict], -) -> None: +) -> bool: """Flush newly committed messages, warning (not failing) on write errors.""" if store is None: - return + return False try: start_index = store.append(messages) except SessionError as error: print(f"warning: could not save session: {error}", file=sys.stderr) - return + return False if start_index is None or ledger is None: - return + return True try: ledger.append_message_events(messages, start_index) except ContextError as error: print(f"warning: could not save context ledger: {error}", file=sys.stderr) + return False + return True + + +def _persist_and_compact( + loop: AgentLoop, + store: SessionStore | None, + ledger: ContextLedger | None, + messages: list[dict], +) -> None: + if not _persist(store, ledger, messages): + return + try: + loop.auto_compact(messages) + except ContextError as error: + print(f"warning: could not compact context: {error}", file=sys.stderr) async def _run_one_shot( @@ -198,7 +215,7 @@ async def _run_one_shot( ) -> int: try: if await _send(loop, messages, prompt): - _persist(store, ledger, messages) + _persist_and_compact(loop, store, ledger, messages) # A one-shot run that launched background work waits it out, reacting to # each completion, so the task is not killed the instant the turn ends. failed = False @@ -256,7 +273,7 @@ async def _run_repl( continue try: if await _send(loop, messages, user_text): - _persist(store, ledger, messages) + _persist_and_compact(loop, store, ledger, messages) except ProviderError as error: print(f"provider error: {error}", file=sys.stderr) @@ -300,7 +317,7 @@ async def _react_to_notice( print(f"\n [background] {notice.task_id} finished (exit {notice.exit_code})") try: if await _send(loop, messages, text): - _persist(store, ledger, messages) + _persist_and_compact(loop, store, ledger, messages) return True return False except ProviderError as error: @@ -360,10 +377,6 @@ def _context_limits_from_env() -> ContextLimits: ) -def _context_limits_from_config(config: Config) -> ContextLimits: - return _context_limits(config.context_max_chars, config.context_recent_turns) - - def _int_from_env(name: str, default: int | None) -> int | None: """Parse an integer env var, or return the default when unset. @@ -489,7 +502,6 @@ async def _async_main(argv: list[str]) -> int: if store is not None else None ) - context_manager = ContextManager(ledger, _context_limits_from_config(config)) print("loading agent bundle...", file=sys.stderr) try: @@ -503,15 +515,23 @@ async def _async_main(argv: list[str]) -> int: print(f"startup error: {error}", file=sys.stderr) return 1 - # The provider binding is resolved only after the manifest loads, so the - # agent's declared profile can supply the model/base_url (config.py §9). + # Provider and context bindings are resolved only after the manifest loads, + # so the agent's declared profile can supply them, with an + # explicit env value still winning per field. try: runtime = resolve_runtime(config, manifest) provider = _build_provider(runtime) + context = resolve_context(config, manifest) except ConfigError as error: print(f"configuration error: {error}", file=sys.stderr) return 1 + context_manager = ContextManager( + ledger, + ContextLimits(max_chars=context.max_chars, recent_turns=context.recent_turns), + auto_compact=context.auto_compact, + ) + jail = JailBuilder( config.work_tree, manifest.shell_path, diff --git a/tartarus/config.py b/tartarus/config.py index dc023f5..446880f 100644 --- a/tartarus/config.py +++ b/tartarus/config.py @@ -1,4 +1,4 @@ -"""Harness configuration, loaded from environment variables (PLAN.md §9). +"""Harness configuration, loaded from environment variables. Provider config is the only thing that changes to switch LLM backends. API keys come from the environment and are never embedded in code. Defaults target OpenCode @@ -20,6 +20,10 @@ from pydantic_settings import BaseSettings, SettingsConfigDict from typing_extensions import Self from tartarus.constants import DEFAULT_OUTPUT_TRUNCATE_CHARS, STRICT_CONFIG +from tartarus.context import ( + DEFAULT_CONTEXT_MAX_CHARS, + DEFAULT_CONTEXT_RECENT_TURNS, +) from tartarus.manifest import Manifest, Sampling DEFAULT_BASE_URL = "https://opencode.ai/zen/v1" @@ -53,7 +57,7 @@ class ConfigError(Exception): class Config(BaseSettings): - """Harness config, loaded from TARTARUS_* environment variables (PLAN.md §9). + """Harness config, loaded from TARTARUS_* environment variables. Each field reads from TARTARUS_; a few keep legacy env names via an explicit alias. Runtime fields (provider/base_url/model/max_tokens) stay None @@ -81,7 +85,7 @@ class Config(BaseSettings): # agent bundle when no realized `bundle_path` is given. flake_ref: str = DEFAULT_FLAKE_REF # A realized agent bundle store path (e.g. received via `nix copy`). When set, - # the harness loads it directly and never touches the flake (PLAN.md §14). + # the harness loads it directly and never touches the flake. bundle_path: str = Field("", validation_alias=AliasChoices("TARTARUS_BUNDLE")) # Agent name under #agents. to load. agent_name: str = Field( @@ -190,7 +194,7 @@ class ResolvedRuntime(BaseModel): def resolve_runtime(config: Config, manifest: Manifest) -> ResolvedRuntime: - """Combine env config and the agent's `model` block by precedence (§9). + """Combine env config and the agent's `model` block by precedence. Per field: an explicit env var wins; otherwise the agent's declared value; otherwise the built-in default. api_key is env-only. @@ -217,3 +221,63 @@ def resolve_runtime(config: Config, manifest: Manifest) -> ResolvedRuntime: # backend applies its own default. sampling=model.sampling if model else None, ) + + +# Compaction stays off unless an agent opts in, so it remains an explicit, +# visible action. +DEFAULT_AUTO_COMPACT = False + + +class ResolvedContext(BaseModel): + """The effective context policy for a run, after applying precedence. + + Per field: an explicit env var wins; otherwise the agent's `context` block; + otherwise the built-in default. + """ + + model_config = STRICT_CONFIG + + max_chars: int + recent_turns: int + auto_compact: bool + + +def resolve_context(config: Config, manifest: Manifest) -> ResolvedContext: + """Combine env config and the agent's `context` block by precedence. + + Mirrors resolve_runtime: an explicit env value wins, else the agent's + declared value, else the built-in default. + """ + block = manifest.context + max_chars = _coalesce_int( + config.context_max_chars, + block.max_chars if block else None, + DEFAULT_CONTEXT_MAX_CHARS, + ) + recent_turns = _coalesce_int( + config.context_recent_turns, + block.recent_turns if block else None, + DEFAULT_CONTEXT_RECENT_TURNS, + ) + if max_chars < 0: + raise ConfigError("context maxChars must be non-negative") + if recent_turns < 0: + raise ConfigError("context recentTurns must be non-negative") + auto_compact = ( + block.auto_compact + if block is not None and block.auto_compact is not None + else DEFAULT_AUTO_COMPACT + ) + return ResolvedContext( + max_chars=max_chars, + recent_turns=recent_turns, + auto_compact=auto_compact, + ) + + +def _coalesce_int(env_value: int | None, block_value: int | None, default: int) -> int: + if env_value is not None: + return env_value + if block_value is not None: + return block_value + return default diff --git a/tartarus/context.py b/tartarus/context.py index 6172aac..7ce8cc9 100644 --- a/tartarus/context.py +++ b/tartarus/context.py @@ -114,9 +114,11 @@ class ContextManager: self, ledger: ContextLedger | None = None, limits: ContextLimits | None = None, + auto_compact: bool = False, ): self._ledger = ledger self._limits = limits or ContextLimits() + self._auto_compact = auto_compact @property def ledger_path(self) -> str | None: @@ -183,6 +185,13 @@ class ContextManager: if suffix_start <= 0: return None + # Compaction is monotonic: never re-summarize a range the latest summary + # already covers, so repeated or automatic compaction appends a new + # summary only when it advances the covered boundary. + latest = latest_summary(self._load_events()) + if latest is not None and int(latest["covered"]["end"]) >= suffix_start: + return None + summary_text = deterministic_summary(messages[:suffix_start]) event = { "type": "context_summary", @@ -194,6 +203,22 @@ class ContextManager: self._ledger.append_event(event) return event + def maybe_compact(self, messages: list[dict]) -> dict[str, Any] | None: + """Auto-compact at a turn boundary when the agent opts in and is over limit. + + A no-op unless `autoCompact` is enabled; otherwise it compacts only when + the effective context already exceeds `max_chars`, reusing the + deterministic compactor so the result stays an explicit ledger event. + """ + if not self._auto_compact or self._ledger is None: + return None + if ( + estimate_messages(self.effective_messages(messages)) + <= self._limits.max_chars + ): + return None + return self.compact(messages) + def _load_events(self) -> list[dict[str, Any]]: if self._ledger is None: return [] diff --git a/tartarus/jail.py b/tartarus/jail.py index 1534e11..fba4658 100644 --- a/tartarus/jail.py +++ b/tartarus/jail.py @@ -1,4 +1,4 @@ -"""The JailBuilder: bwrap confinement for brokered commands (PLAN.md §6.7). +"""The JailBuilder: bwrap confinement for brokered commands. Builds a JailSpec from a capability grant and executes the command inside bubblewrap. The jail binds only the declared shell and capability closures @@ -272,7 +272,7 @@ class JailBuilder: self._bwrap_path, # Only this call's closure is visible, read-only — not the whole # store. bwrap synthesizes the /nix/store parent, so the in-jail - # store contains exactly the bound closure (PLAN.md §13). + # store contains exactly the bound closure. *_store_bind_args(spec.bind_paths), *self._work_tree_bind_args(spec), *self._writable_bind_args(spec), diff --git a/tartarus/manifest.py b/tartarus/manifest.py index 6a21402..60a00d9 100644 --- a/tartarus/manifest.py +++ b/tartarus/manifest.py @@ -132,7 +132,7 @@ class Capability(BaseModel): # Per-capability wall-clock budget in seconds. None means the capability runs # unbounded; a declared value caps it at that many seconds. timeout: int | None = None - # How the broker runs this capability (PLAN.md §6.5): + # How the broker runs this capability: # "command" — run in the jail, capture output, return one result (default). # "background" — launch detached, return a handle; track in the registry. # "control" — operate on the background registry (see `control`); no jail. @@ -219,7 +219,7 @@ _RESERVED_SAMPLING_KEYS = frozenset( class ModelConfig(BaseModel): - """The model an agent declares: backend binding + inference knobs (PLAN.md §9). + """The model an agent declares: backend binding + inference knobs. A model id is only meaningful next to the base_url that serves it, so they travel together alongside provider-portable inference knobs. Secrets and @@ -268,6 +268,33 @@ class ModelConfig(BaseModel): return v +# ── ContextConfig ──────────────────────────────────────────────────────────── + + +class ContextConfig(BaseModel): + """The context policy an agent declares in Nix. + + Every field is optional: a None field falls back to an explicit env override + or the harness built-in default (resolve_context). `autoCompact` defaults to + off so compaction stays an explicit, visible action unless the agent opts in. + """ + + model_config = STRICT_CONFIG + + max_chars: int | None = None + recent_turns: int | None = None + auto_compact: bool | None = None + + @field_validator("max_chars", "recent_turns", mode="before") + @classmethod + def _reject_negative_ints(cls, v: object) -> int | None: + if v is None: + return None + if isinstance(v, bool) or not isinstance(v, int) or v < 0: + raise ValueError("must be a non-negative integer") + return v + + # ── Manifest ───────────────────────────────────────────────────────────────── @@ -281,6 +308,9 @@ class Manifest(BaseModel): # The agent's model block, declared in Nix. None when the agent declares none, # in which case the harness defaults (or env overrides) supply everything. model: ModelConfig | None = None + # The agent's context policy, declared in Nix. None when the agent declares + # none, in which case env overrides or harness defaults supply the limits. + context: ContextConfig | None = None # The CA bundle path emitted by the agent flake. Exported into jailed tools # (base_env), and the Nix shell closure binds its store root. ca_bundle_file: str = "" diff --git a/tartarus/manifest_loader.py b/tartarus/manifest_loader.py index 439f085..1d46c81 100644 --- a/tartarus/manifest_loader.py +++ b/tartarus/manifest_loader.py @@ -1,4 +1,4 @@ -"""Manifest validation (§5). Pure contract checks over decoded manifest JSON. +"""Manifest validation. Pure contract checks over decoded manifest JSON. The manifest is loaded from a realized agent bundle by `tartarus.bundle`; this module holds the validation. `build_manifest_from_raw` is pure — it takes the @@ -140,6 +140,8 @@ def _map_manifest_raw(raw: dict[str, Any]) -> dict[str, Any]: mapped["system_prompt"] = raw["systemPrompt"] if "model" in raw: mapped["model"] = _map_model_raw(raw["model"]) + if "context" in raw: + mapped["context"] = _map_context_raw(raw["context"]) return mapped @@ -209,6 +211,29 @@ def _map_model_raw(raw: object) -> dict[str, Any]: return mapped +_KNOWN_CONTEXT_KEYS = frozenset({"maxChars", "recentTurns", "autoCompact"}) + + +def _map_context_raw(raw: object) -> dict[str, Any]: + body = _require_object(raw, "manifest 'context'") + + unknown_keys = sorted(set(body) - _KNOWN_CONTEXT_KEYS) + if unknown_keys: + raise ManifestError( + "manifest 'context' has unsupported keys: " + ", ".join(unknown_keys) + ) + + mapped: dict[str, Any] = {} + for json_key, py_key in ( + ("maxChars", "max_chars"), + ("recentTurns", "recent_turns"), + ("autoCompact", "auto_compact"), + ): + if json_key in body: + mapped[py_key] = body[json_key] + return mapped + + def _require_object(value: object, label: str) -> dict[str, Any]: """Return value as a dict, or fail closed with a contextual message. diff --git a/tartarus/models.py b/tartarus/models.py index 64b6c2c..8298dbd 100644 --- a/tartarus/models.py +++ b/tartarus/models.py @@ -2,7 +2,7 @@ These shapes are deliberately independent of any vendor wire format. Each concrete Provider translates between these and its backend's request/response JSON, so the -AgentLoop and Broker never branch on which backend is configured (PLAN.md §6.3). +AgentLoop and Broker never branch on which backend is configured. """ from dataclasses import dataclass diff --git a/tartarus/policy.py b/tartarus/policy.py index c3304e5..1ef0ebf 100644 --- a/tartarus/policy.py +++ b/tartarus/policy.py @@ -1,4 +1,4 @@ -"""PolicyEngine: gate each tool call per its capability's policy (PLAN.md §6.6). +"""PolicyEngine: gate each tool call per its capability's policy. - auto → allow without asking. - ask-once → prompt the first time this session, then remember (keyed by name). @@ -8,7 +8,7 @@ The prompt shows the human the exact delta being requested — the capability, its grant deltas, and the interpolated command — and requires an explicit y/N, defaulting to No. In headless mode there is no human, so every ask-* policy denies -(fail closed, PLAN.md §8.4). +(fail closed). """ import sys diff --git a/tartarus/provider/__init__.py b/tartarus/provider/__init__.py index 6102d9c..62a07b4 100644 --- a/tartarus/provider/__init__.py +++ b/tartarus/provider/__init__.py @@ -1 +1 @@ -"""LLM backend providers — the only place vendor wire-format lives (PLAN.md §6.3).""" +"""LLM backend providers — the only place vendor wire-format lives.""" diff --git a/tartarus/provider/base.py b/tartarus/provider/base.py index c838b8b..b58bb9b 100644 --- a/tartarus/provider/base.py +++ b/tartarus/provider/base.py @@ -1,4 +1,4 @@ -"""The Provider protocol (PLAN.md §6.3). +"""The Provider protocol. The AgentLoop talks to providers in provider-neutral terms; each concrete provider adapts to exactly one wire format. Completion is async so the harness can drive the diff --git a/tartarus/provider/openai_compat.py b/tartarus/provider/openai_compat.py index f72a780..87b06ef 100644 --- a/tartarus/provider/openai_compat.py +++ b/tartarus/provider/openai_compat.py @@ -1,4 +1,4 @@ -"""OpenAI-compatible chat-completions provider (PLAN.md §6.3). +"""OpenAI-compatible chat-completions provider. Targets POST {base_url}/chat/completions, which serves OpenCode Zen, OpenAI, Together, local Ollama/llama.cpp/vLLM, and most gateways. Uses raw HTTP via httpx diff --git a/tartarus/session.py b/tartarus/session.py index 82b5c44..e1749d0 100644 --- a/tartarus/session.py +++ b/tartarus/session.py @@ -1,4 +1,4 @@ -"""Append-only JSONL persistence for conversation transcripts (PLAN.md §10). +"""Append-only JSONL persistence for conversation transcripts. A session is the provider-native `messages` list the AgentLoop maintains. The loop only ever appends to it, and only at whole-round-trip boundaries, so a session diff --git a/tartarus/shell.py b/tartarus/shell.py index 3433b2d..202058d 100644 --- a/tartarus/shell.py +++ b/tartarus/shell.py @@ -1,7 +1,7 @@ """Minimal shell-path helper for tests and ad-hoc store resolution. The harness no longer resolves a live shell: an agent's baseline PATH is baked -into its bundle manifest (`shellPath`) at build time (PLAN.md §14). What remains +into its bundle manifest (`shellPath`) at build time. What remains here is `resolve_minimal_shell_path`, used by the jail integration tests to build a small PATH from named packages. """ diff --git a/tests/test_agent_loop.py b/tests/test_agent_loop.py index 1707f03..3f51c18 100644 --- a/tests/test_agent_loop.py +++ b/tests/test_agent_loop.py @@ -421,6 +421,41 @@ def test_loop_sends_effective_messages_without_mutating_raw_transcript(tmp_path) ] +def test_loop_does_not_auto_compact_before_provider_call(tmp_path): + manifest = echo_manifest() + ledger = ContextLedger(str(tmp_path), "s1") + provider = ScriptedProvider( + [ + AssistantTurn( + text="done", + tool_calls=[], + raw={"role": "assistant"}, + stop_reason="end", + ) + ] + ) + loop = AgentLoop( + provider, + Broker(manifest, cast(JailBuilder, LocalJail()), PolicyEngine()), + manifest, + "system", + ContextManager( + ledger, + ContextLimits(max_chars=100, recent_turns=1), + auto_compact=True, + ), + ) + messages = [ + {"role": "user", "content": "old " + "x" * 200}, + {"role": "assistant", "content": "old reply " + "y" * 200}, + {"role": "user", "content": "new " + "z" * 200}, + ] + + asyncio.run(_drain(loop, messages)) + + assert [event["type"] for event in ledger.load_events()] == [] + + def test_loop_handles_context_status_as_internal_tool(tmp_path): manifest = echo_manifest() provider = ScriptedProvider( diff --git a/tests/test_bundle.py b/tests/test_bundle.py index 9fdb6cd..fcc3007 100644 --- a/tests/test_bundle.py +++ b/tests/test_bundle.py @@ -1,4 +1,4 @@ -"""Bundle loading (PLAN.md §14): build/locate a bundle, read it with no nix.""" +"""Bundle loading: build/locate a bundle, read it with no nix.""" import json import shutil diff --git a/tests/test_cli.py b/tests/test_cli.py index 69c7710..b7e5cdd 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -11,13 +11,16 @@ from tartarus.cli import ( _bundle_manifest_source, _parse_agent_selector, _parse_session_flags, + _persist_and_compact, _print_context_status, _run_one_shot, ) from tartarus.config import ConfigError +from tartarus.context import ContextLedger, ContextLimits, ContextManager from tartarus.jail import JailBuilder from tartarus.policy import PolicyEngine from tartarus.provider.base import Provider +from tartarus.session import SessionStore from tests.manifest_fixtures import echo_manifest @@ -154,6 +157,36 @@ def test_print_context_status_uses_latest_session_without_api_key( assert "ledger events: 0" in captured.out +def test_persist_and_compact_writes_summary_after_transcript_events(tmp_path): + store = SessionStore(str(tmp_path / "sessions"), "s1") + ledger = ContextLedger(str(tmp_path / "context"), "s1") + manager = ContextManager( + ledger, + ContextLimits(max_chars=500, recent_turns=1), + auto_compact=True, + ) + loop = AgentLoop( + provider=cast(Provider, None), + broker=Broker(echo_manifest(), cast(JailBuilder, None), PolicyEngine()), + manifest=echo_manifest(), + system_prompt="test", + context_manager=manager, + ) + messages = [] + for index in range(5): + messages.append({"role": "user", "content": f"question {index} " + "x" * 200}) + messages.append( + {"role": "assistant", "content": f"answer {index} " + "y" * 200} + ) + + _persist_and_compact(loop, store, ledger, messages) + + events = ledger.load_events() + assert events[-1]["type"] == "context_summary" + assert [event["message_index"] for event in events[:-1]] == list(range(10)) + assert events[-1]["covered"] == {"start": 0, "end": 8} + + def test_run_one_shot_returns_one_when_background_reaction_fails(monkeypatch): """A provider-level failure while reacting to a completion yields exit code 1.""" diff --git a/tests/test_config.py b/tests/test_config.py index 52bb795..52e0d80 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -4,15 +4,18 @@ import pytest from tartarus.config import ( API_KEY_ENV_VARS, + DEFAULT_AUTO_COMPACT, DEFAULT_BASE_URL, DEFAULT_MAX_TOKENS, DEFAULT_MODEL, Config, ConfigError, load_config, + resolve_context, resolve_runtime, ) -from tartarus.manifest import Manifest, ModelConfig +from tartarus.context import DEFAULT_CONTEXT_MAX_CHARS, DEFAULT_CONTEXT_RECENT_TURNS +from tartarus.manifest import ContextConfig, Manifest, ModelConfig @pytest.fixture(autouse=True) @@ -140,3 +143,45 @@ def test_resolve_runtime_env_overrides_manifest_model(): assert runtime.base_url == "https://env.example/v1" assert runtime.model == "env-model" assert runtime.max_tokens == 2048 + + +def test_resolve_context_falls_back_to_defaults(): + # Neither env nor manifest declares a policy: built-in defaults apply. + context = resolve_context(Config(api_key="secret"), _manifest()) + + assert context.max_chars == DEFAULT_CONTEXT_MAX_CHARS + assert context.recent_turns == DEFAULT_CONTEXT_RECENT_TURNS + assert context.auto_compact is DEFAULT_AUTO_COMPACT + + +def test_resolve_context_uses_manifest_block_over_defaults(): + manifest = _manifest( + context=ContextConfig(max_chars=5000, recent_turns=3, auto_compact=True), + ) + + context = resolve_context(Config(api_key="secret"), manifest) + + assert context.max_chars == 5000 + assert context.recent_turns == 3 + assert context.auto_compact is True + + +def test_resolve_context_env_overrides_manifest_block_per_field(): + # An explicit env value wins per field; unset fields fall to the manifest. + config = Config(api_key="secret", context_max_chars=9999) + manifest = _manifest( + context=ContextConfig(max_chars=5000, recent_turns=3, auto_compact=True), + ) + + context = resolve_context(config, manifest) + + assert context.max_chars == 9999 # env wins + assert context.recent_turns == 3 # manifest fills the rest + assert context.auto_compact is True + + +def test_resolve_context_rejects_negative_env_value(): + config = Config(api_key="secret", context_recent_turns=-1) + + with pytest.raises(ConfigError, match="recentTurns must be non-negative"): + resolve_context(config, _manifest()) diff --git a/tests/test_context.py b/tests/test_context.py index f4dde7b..55f5db9 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -187,3 +187,52 @@ def test_deterministic_summary_mentions_tool_calls_and_results(): assert "tool call: bash" in summary assert "tool result 2 (call-1): ok" in summary + + +def _wordy_messages(turns: int) -> list[dict]: + messages: list[dict] = [] + for index in range(turns): + messages.append({"role": "user", "content": f"question {index} " + "x" * 200}) + messages.append( + {"role": "assistant", "content": f"answer {index} " + "y" * 200} + ) + return messages + + +def test_maybe_compact_is_noop_when_auto_compact_disabled(tmp_path): + ledger = ContextLedger(str(tmp_path), "s1") + manager = ContextManager(ledger, ContextLimits(max_chars=10, recent_turns=1)) + + assert manager.maybe_compact(_wordy_messages(5)) is None + assert ledger.load_events() == [] + + +def test_maybe_compact_is_noop_under_limit(tmp_path): + ledger = ContextLedger(str(tmp_path), "s1") + manager = ContextManager( + ledger, + ContextLimits(max_chars=1_000_000, recent_turns=1), + auto_compact=True, + ) + + assert manager.maybe_compact(_wordy_messages(5)) is None + assert ledger.load_events() == [] + + +def test_maybe_compact_compacts_once_when_over_limit(tmp_path): + ledger = ContextLedger(str(tmp_path), "s1") + manager = ContextManager( + ledger, + ContextLimits(max_chars=500, recent_turns=1), + auto_compact=True, + ) + messages = _wordy_messages(5) + + event = manager.maybe_compact(messages) + assert event is not None + assert event["type"] == "context_summary" + + # Monotonic: a second call with the same transcript adds no new summary. + assert manager.maybe_compact(messages) is None + summaries = [e for e in ledger.load_events() if e["type"] == "context_summary"] + assert len(summaries) == 1 diff --git a/tests/test_jail.py b/tests/test_jail.py index aa17a0e..5070bb8 100644 --- a/tests/test_jail.py +++ b/tests/test_jail.py @@ -1,4 +1,4 @@ -"""Integration tests for the bwrap jail (PLAN.md §11). +"""Integration tests for the bwrap jail. These require Linux with `bwrap` and `nix` present, so they skip elsewhere. They prove the security invariants: confinement, content purity, and reach @@ -175,7 +175,7 @@ def test_host_only_tool_is_absent_inside_jail(tmp_path, shell_path, shell_closur def test_ungranted_tool_unreachable_by_absolute_store_path( tmp_path, shell_path, shell_closure ): - # The store-bind purity gap (PLAN.md §13): with the whole store mounted, an + # The store-bind purity gap: with the whole store mounted, an # un-granted binary was reachable by absolute path. Now only the closure is # bound, so git's own store path does not exist inside the jail even though # its dependencies (bash, coreutils) are part of the shell closure. diff --git a/tests/test_manifest_loader.py b/tests/test_manifest_loader.py index edccaa1..6b141f9 100644 --- a/tests/test_manifest_loader.py +++ b/tests/test_manifest_loader.py @@ -11,7 +11,7 @@ from tartarus.manifest_loader import ( def _valid_raw(): - """A minimal manifest that satisfies every §5 rule.""" + """A minimal manifest that satisfies every validation rule.""" return { "tools": [ { @@ -259,6 +259,54 @@ def test_model_extra_headers_are_rejected(): build_manifest_from_raw(raw) +# --- context block ---------------------------------------------------------- + + +def test_context_block_absent_leaves_context_none(): + # Optional, like the model block; absent means defaults/env supply the limits. + assert build_manifest_from_raw(_valid_raw()).context is None + + +def test_context_block_is_read_when_present(): + raw = _valid_raw() + raw["context"] = {"maxChars": 5000, "recentTurns": 3, "autoCompact": True} + + context = build_manifest_from_raw(raw).context + + assert context is not None + assert context.max_chars == 5000 + assert context.recent_turns == 3 + assert context.auto_compact is True + + +def test_context_block_is_partial_friendly(): + raw = _valid_raw() + raw["context"] = {"recentTurns": 8} + + context = build_manifest_from_raw(raw).context + + assert context is not None + assert context.recent_turns == 8 + assert context.max_chars is None + assert context.auto_compact is None + + +def test_negative_context_value_is_rejected(): + raw = _valid_raw() + raw["context"] = {"maxChars": -1} + + with pytest.raises(ManifestError, match="non-negative"): + build_manifest_from_raw(raw) + + +def test_unsupported_context_key_is_rejected(): + raw = _valid_raw() + raw["context"] = {"summarizer": "model"} + + with pytest.raises(ManifestError, match="unsupported keys: summarizer"): + build_manifest_from_raw(raw) + + # --- fail-closed validation ------------------------------------------------- @@ -290,9 +338,7 @@ def test_reserved_context_capability_name_is_rejected(reserved_name): def test_reserved_context_tool_name_without_capability_is_rejected(): raw = _valid_raw() - raw["tools"].append( - {"name": "context_status", "description": "", "parameters": {}} - ) + raw["tools"].append({"name": "context_status", "description": "", "parameters": {}}) with pytest.raises(ManifestError, match="'context_status' is reserved"): build_manifest_from_raw(raw) @@ -469,7 +515,7 @@ def test_validate_realized_package_bins_fails_when_grant_env_is_incomplete(monke validate_realized_package_bins(manifest) -# --- closure resolution (PLAN.md §13) --------------------------------------- +# --- closure resolution ----------------------------------------------------- def test_malformed_closure_reference_is_rejected():