A declarative, hermetic harness for Nix-defined agents.
0

Configure Feed

Select the types of activity you want to include in your feed.

context: add ledger and deterministic compaction

+898 -21
+1
AGENTS.md
··· 105 105 output is forced (reading `config` is free), mirroring `system.build.toplevel`. 106 106 - Capabilities are keyed attrsets under `capabilities.<name>`. Do not put 107 107 `name` in the capability body; the attrset key is the identity. 108 + `context_status` and `context_read` are reserved internal tool names. 108 109 - The agent's `shell` is the baseline PATH baked into the manifest; keep it 109 110 minimal. Tool-specific programs go in that capability's `grants.packages`. 110 111 `shell.env` adds env vars to every call (reserved names rejected at build
+1
README.md
··· 190 190 A capability declares: 191 191 192 192 - the attrset key as its name, plus `description` and model-facing `params` 193 + (`context_status` and `context_read` are reserved for internal context tools) 193 194 - `policy`: `auto`, `ask-once`, `ask-always`, or `deny` 194 195 - `grants.packages`: package binaries available only to that tool 195 196 - `grants.network.allowedHosts`: proxy-allowed HTTP(S) hosts
+9
lib/agents.nix
··· 125 125 || grant.writable != [ ] 126 126 || grant.unrestricted; 127 127 128 + reservedCapabilityNames = [ 129 + "context_status" 130 + "context_read" 131 + ]; 132 + 128 133 capabilityType = { 129 134 options = { 130 135 description = lib.mkOption { ··· 197 202 capabilityAssertions = 198 203 name: capability: 199 204 [ 205 + { 206 + assertion = !(lib.elem name reservedCapabilityNames); 207 + message = "Tartarus capability '${name}' uses a reserved internal tool name."; 208 + } 200 209 { 201 210 assertion = !(capability.grants.unrestricted && capability.policy == "auto"); 202 211 message = "Tartarus capability '${name}' cannot combine unrestricted = true with policy = \"auto\".";
+20 -3
tartarus/agent_loop.py
··· 15 15 from dataclasses import dataclass 16 16 17 17 from tartarus.broker import Broker 18 + from tartarus.context import CONTEXT_TOOL_NAMES, CONTEXT_TOOLS, ContextManager 18 19 from tartarus.manifest import Manifest 19 20 from tartarus.models import ( 20 21 TextDelta, ··· 48 49 broker: Broker, 49 50 manifest: Manifest, 50 51 system_prompt: str, 52 + context_manager: ContextManager | None = None, 51 53 ): 52 54 self._provider = provider 53 55 self._broker = broker 54 56 self._manifest = manifest 55 57 self._system_prompt = system_prompt 58 + self._context_manager = context_manager or ContextManager() 56 59 57 60 async def run_turn(self, messages: list[dict]): 58 61 """Drive one human turn to completion, yielding UI events as they happen. ··· 65 68 while True: 66 69 text_parts: list[str] = [] 67 70 turn = None 71 + effective_messages = self._context_manager.effective_messages(messages) 68 72 async for event in self._provider.stream( 69 - self._system_prompt, messages, self._manifest.tools 73 + self._system_prompt, effective_messages, self._tools() 70 74 ): 71 75 if isinstance(event, TextDelta): 72 76 text_parts.append(event.text) ··· 86 90 for call in turn.tool_calls: 87 91 yield ToolStarted(call) 88 92 result = None 89 - async for tool_event in self._run_tool(call): 93 + async for tool_event in self._run_tool(call, messages): 90 94 if isinstance(tool_event, ToolOutputDelta): 91 95 yield tool_event 92 96 else: ··· 100 104 messages.append(assistant_message) 101 105 messages.extend(self._provider.tool_result_messages(results)) 102 106 103 - async def _run_tool(self, call: ToolCall): 107 + async def _run_tool(self, call: ToolCall, messages: list[dict]): 108 + # Context tools only inspect local context state — no host reach to gate — 109 + # so they answer here directly, bypassing the broker/jail/policy/audit path. 110 + if call.name in CONTEXT_TOOL_NAMES: 111 + yield ToolResult( 112 + call.id, 113 + self._context_manager.handle_tool(call.name, call.arguments, messages), 114 + is_error=False, 115 + ) 116 + return 117 + 104 118 output_queue: asyncio.Queue[str] = asyncio.Queue() 105 119 106 120 # The broker runs on this loop, so streamed output lands on the queue ··· 142 156 finally: 143 157 if pending_get is not None: 144 158 pending_get.cancel() 159 + 160 + def _tools(self) -> list[dict]: 161 + return [*self._manifest.tools, *CONTEXT_TOOLS]
+151 -12
tartarus/cli.py
··· 8 8 """ 9 9 10 10 import asyncio 11 + import os 11 12 import signal 12 13 import sys 13 14 from dataclasses import dataclass ··· 16 17 from tartarus.audit import FileAuditLog 17 18 from tartarus.background import BackgroundRegistry, Notice 18 19 from tartarus.broker import Broker 20 + from tartarus.bundle import BundleError, base_env_from, load_bundle, resolve_bundle 19 21 from tartarus.config import ( 20 22 Config, 21 23 ConfigError, 22 24 ResolvedRuntime, 25 + context_dir_from_env, 23 26 load_config, 24 27 resolve_runtime, 25 28 session_dir_from_env, 26 29 ) 27 - from tartarus.bundle import BundleError, base_env_from, load_bundle, resolve_bundle 30 + from tartarus.context import ContextError, ContextLedger, ContextLimits, ContextManager 28 31 from tartarus.jail import JailBuilder 29 32 from tartarus.manifest_loader import host_system 30 33 from tartarus.models import TextDelta, ToolOutputDelta ··· 41 44 continue_latest: bool = False # --continue: reopen the most recent 42 45 disabled: bool = False # --no-session: don't persist 43 46 list_sessions: bool = False # --list-sessions: print and exit 47 + context_status: bool = False # --context-status: print status and exit 48 + compact_context: bool = False # --compact-context: compact current session and exit 44 49 45 50 46 51 def _parse_session_flags(argv: list[str]) -> tuple[SessionFlags, list[str]]: ··· 60 65 flags.disabled = True 61 66 elif arg == "--list-sessions": 62 67 flags.list_sessions = True 68 + elif arg == "--context-status": 69 + flags.context_status = True 70 + elif arg == "--compact-context": 71 + flags.compact_context = True 63 72 elif arg == "--resume": 64 73 if i + 1 >= len(argv): 65 74 raise ConfigError("--resume requires a session id") ··· 157 166 pass 158 167 159 168 160 - def _persist(store: SessionStore | None, messages: list[dict]) -> None: 169 + def _persist( 170 + store: SessionStore | None, 171 + ledger: ContextLedger | None, 172 + messages: list[dict], 173 + ) -> None: 161 174 """Flush newly committed messages, warning (not failing) on write errors.""" 162 175 if store is None: 163 176 return 164 177 try: 165 - store.append(messages) 178 + start_index = store.append(messages) 166 179 except SessionError as error: 167 180 print(f"warning: could not save session: {error}", file=sys.stderr) 181 + return 182 + if start_index is None or ledger is None: 183 + return 184 + try: 185 + ledger.append_message_events(messages, start_index) 186 + except ContextError as error: 187 + print(f"warning: could not save context ledger: {error}", file=sys.stderr) 168 188 169 189 170 190 async def _run_one_shot( ··· 172 192 prompt: str, 173 193 messages: list[dict], 174 194 store: SessionStore | None, 195 + ledger: ContextLedger | None, 175 196 registry: BackgroundRegistry, 176 197 notices: "asyncio.Queue[Notice]", 177 198 ) -> int: 178 199 try: 179 200 if await _send(loop, messages, prompt): 180 - _persist(store, messages) 201 + _persist(store, ledger, messages) 181 202 # A one-shot run that launched background work waits it out, reacting to 182 203 # each completion, so the task is not killed the instant the turn ends. 183 204 failed = False 184 205 while registry.has_running or not notices.empty(): 185 - if not await _drain_notice(loop, messages, store, notices): 206 + if not await _drain_notice(loop, messages, store, ledger, notices): 186 207 failed = True 187 208 return 1 if failed else 0 188 209 except ProviderError as error: ··· 194 215 loop: AgentLoop, 195 216 messages: list[dict], 196 217 store: SessionStore | None, 218 + ledger: ContextLedger | None, 197 219 registry: BackgroundRegistry, 198 220 notices: "asyncio.Queue[Notice]", 199 221 ) -> int: ··· 218 240 continue 219 241 220 242 if notice_task in done: 221 - await _react_to_notice(loop, messages, store, notice_task.result()) 243 + await _react_to_notice(loop, messages, store, ledger, notice_task.result()) 222 244 continue 223 245 224 246 # notice_task did not win, so the input task is the one that completed. ··· 234 256 continue 235 257 try: 236 258 if await _send(loop, messages, user_text): 237 - _persist(store, messages) 259 + _persist(store, ledger, messages) 238 260 except ProviderError as error: 239 261 print(f"provider error: {error}", file=sys.stderr) 240 262 ··· 247 269 loop: AgentLoop, 248 270 messages: list[dict], 249 271 store: SessionStore | None, 272 + ledger: ContextLedger | None, 250 273 notices: "asyncio.Queue[Notice]", 251 274 ) -> bool: 252 - return await _react_to_notice(loop, messages, store, await notices.get()) 275 + return await _react_to_notice(loop, messages, store, ledger, await notices.get()) 253 276 254 277 255 278 async def _react_to_notice( 256 279 loop: AgentLoop, 257 280 messages: list[dict], 258 281 store: SessionStore | None, 282 + ledger: ContextLedger | None, 259 283 notice: Notice, 260 284 ) -> bool: 261 285 """Turn one background completion into a transcript message + follow-up turn. ··· 276 300 print(f"\n [background] {notice.task_id} finished (exit {notice.exit_code})") 277 301 try: 278 302 if await _send(loop, messages, text): 279 - _persist(store, messages) 303 + _persist(store, ledger, messages) 280 304 return True 281 305 return False 282 306 except ProviderError as error: ··· 308 332 print(f"{session_id} {preview}") 309 333 310 334 335 + def _context_limits(max_chars: int | None, recent_turns: int | None) -> ContextLimits: 336 + """Validate context limits, falling back to defaults for unset (None) values. 337 + 338 + The single resolution point for both the env-only inspection path and the 339 + config-driven live run, so they cannot validate differently. 340 + """ 341 + defaults = ContextLimits() 342 + resolved_max_chars = max_chars if max_chars is not None else defaults.max_chars 343 + resolved_recent_turns = ( 344 + recent_turns if recent_turns is not None else defaults.recent_turns 345 + ) 346 + if resolved_max_chars < 0: 347 + raise ConfigError("TARTARUS_CONTEXT_MAX_CHARS must be non-negative") 348 + if resolved_recent_turns < 0: 349 + raise ConfigError("TARTARUS_CONTEXT_RECENT_TURNS must be non-negative") 350 + return ContextLimits( 351 + max_chars=resolved_max_chars, 352 + recent_turns=resolved_recent_turns, 353 + ) 354 + 355 + 356 + def _context_limits_from_env() -> ContextLimits: 357 + return _context_limits( 358 + _int_from_env("TARTARUS_CONTEXT_MAX_CHARS", None), 359 + _int_from_env("TARTARUS_CONTEXT_RECENT_TURNS", None), 360 + ) 361 + 362 + 363 + def _context_limits_from_config(config: Config) -> ContextLimits: 364 + return _context_limits(config.context_max_chars, config.context_recent_turns) 365 + 366 + 367 + def _int_from_env(name: str, default: int | None) -> int | None: 368 + """Parse an integer env var, or return the default when unset. 369 + 370 + Range validation lives in _context_limits, the single resolution point; this 371 + only turns the raw string into an int. 372 + """ 373 + value = os.environ.get(name) 374 + if value is None or value == "": 375 + return default 376 + try: 377 + return int(value) 378 + except ValueError as error: 379 + raise ConfigError(f"{name} must be an integer") from error 380 + 381 + 382 + def _resolve_read_only_session(flags: SessionFlags) -> tuple[SessionStore, list[dict]]: 383 + session_dir = session_dir_from_env() 384 + if flags.disabled: 385 + raise SessionError("--no-session cannot be combined with context inspection") 386 + if flags.resume is not None: 387 + session_id = SessionStore.resolve(session_dir, flags.resume) 388 + else: 389 + session_id = SessionStore.latest(session_dir) 390 + if session_id is None: 391 + raise SessionError(f"no sessions in {session_dir}") 392 + store = SessionStore(session_dir, session_id) 393 + return store, store.load() 394 + 395 + 396 + def _print_context_status(flags: SessionFlags) -> int: 397 + try: 398 + store, messages = _resolve_read_only_session(flags) 399 + ledger = ContextLedger(context_dir_from_env(), store.session_id) 400 + manager = ContextManager(ledger, _context_limits_from_env()) 401 + status = manager.status(messages) 402 + except (ConfigError, ContextError, SessionError) as error: 403 + print(f"configuration error: {error}", file=sys.stderr) 404 + return 1 405 + print(f"session: {store.session_id}") 406 + print(f"messages: {status.message_count}") 407 + print(f"estimated context chars: {status.estimated_chars}") 408 + print(f"effective messages: {status.effective_message_count}") 409 + print(f"effective estimated chars: {status.effective_estimated_chars}") 410 + print(f"ledger events: {status.ledger_event_count}") 411 + print(f"ledger: {status.ledger_path}") 412 + return 0 413 + 414 + 415 + def _compact_context(flags: SessionFlags) -> int: 416 + try: 417 + store, messages = _resolve_read_only_session(flags) 418 + ledger = ContextLedger(context_dir_from_env(), store.session_id) 419 + event = ContextManager(ledger, _context_limits_from_env()).compact(messages) 420 + except (ConfigError, ContextError, SessionError) as error: 421 + print(f"configuration error: {error}", file=sys.stderr) 422 + return 1 423 + if event is None: 424 + print(f"session: {store.session_id}") 425 + print("compaction: nothing to compact") 426 + print(f"ledger: {ledger.path}") 427 + return 0 428 + covered = event["covered"] 429 + print(f"session: {store.session_id}") 430 + print(f"compacted messages: {covered['start']}-{covered['end']}") 431 + print(f"ledger: {ledger.path}") 432 + return 0 433 + 434 + 311 435 def _open_session( 312 436 config: Config, flags: SessionFlags 313 437 ) -> tuple[SessionStore | None, list[dict]]: ··· 345 469 if session_flags.list_sessions: 346 470 _print_session_list(session_dir_from_env()) 347 471 return 0 472 + if session_flags.context_status: 473 + return _print_context_status(session_flags) 474 + if session_flags.compact_context: 475 + return _compact_context(session_flags) 348 476 349 477 try: 350 478 config = load_config() ··· 355 483 except (ConfigError, SessionError) as error: 356 484 print(f"configuration error: {error}", file=sys.stderr) 357 485 return 1 486 + 487 + ledger = ( 488 + ContextLedger(config.context_dir, store.session_id) 489 + if store is not None 490 + else None 491 + ) 492 + context_manager = ContextManager(ledger, _context_limits_from_config(config)) 358 493 359 494 print("loading agent bundle...", file=sys.stderr) 360 495 try: ··· 406 541 ) 407 542 # The agent's Nix definition owns its persona; the config default is a fallback. 408 543 system_prompt = manifest.system_prompt or config.system_prompt 409 - loop = AgentLoop(provider, broker, manifest, system_prompt) 544 + loop = AgentLoop(provider, broker, manifest, system_prompt, context_manager) 410 545 411 546 tool_names = ", ".join(tool["name"] for tool in manifest.tools) 412 547 mode = "headless" if config.headless else "interactive" ··· 421 556 print(f"audit log: {config.audit_path}", file=sys.stderr) 422 557 if store is not None: 423 558 print(f"session: {store.session_id} ({store.path})", file=sys.stderr) 559 + if ledger is not None: 560 + print(f"context ledger: {ledger.path}", file=sys.stderr) 424 561 425 562 prompt = " ".join(argv).strip() 426 563 try: 427 564 if prompt: 428 - return await _run_one_shot(loop, prompt, messages, store, registry, notices) 429 - return await _run_repl(loop, messages, store, registry, notices) 565 + return await _run_one_shot( 566 + loop, prompt, messages, store, ledger, registry, notices 567 + ) 568 + return await _run_repl(loop, messages, store, ledger, registry, notices) 430 569 finally: 431 570 registry.shutdown_all() 432 571
+14
tartarus/config.py
··· 32 32 # Leaf names under <work_tree>/.tartarus, shared by every path-deriving call site. 33 33 AUDIT_LOG_LEAF = "audit.jsonl" 34 34 SESSIONS_LEAF = "sessions" 35 + CONTEXT_LEAF = "context" 35 36 # `path:` copies the directory regardless of git tracking, which keeps local 36 37 # capability edits visible before they are committed. 37 38 DEFAULT_FLAKE_REF = "path:." ··· 92 93 audit_path: str = "" 93 94 # Directory holding per-conversation transcript files (<id>.jsonl). 94 95 session_dir: str = Field("", validation_alias=AliasChoices("TARTARUS_SESSIONS_DIR")) 96 + # Directory holding append-only per-session context ledgers. 97 + context_dir: str = Field("", validation_alias=AliasChoices("TARTARUS_CONTEXT_DIR")) 98 + context_max_chars: int | None = None 99 + context_recent_turns: int | None = None 95 100 output_truncate: int = DEFAULT_OUTPUT_TRUNCATE_CHARS 96 101 97 102 @model_validator(mode="after") ··· 102 107 self.audit_path = _default_state_path(self.work_tree, AUDIT_LOG_LEAF) 103 108 if not self.session_dir: 104 109 self.session_dir = _default_state_path(self.work_tree, SESSIONS_LEAF) 110 + if not self.context_dir: 111 + self.context_dir = _default_state_path(self.work_tree, CONTEXT_LEAF) 105 112 return self 106 113 107 114 ··· 114 121 work_tree = os.environ.get("TARTARUS_WORK_TREE") or os.getcwd() 115 122 return os.environ.get("TARTARUS_SESSIONS_DIR") or _default_state_path( 116 123 work_tree, SESSIONS_LEAF 124 + ) 125 + 126 + 127 + def context_dir_from_env() -> str: 128 + work_tree = os.environ.get("TARTARUS_WORK_TREE") or os.getcwd() 129 + return os.environ.get("TARTARUS_CONTEXT_DIR") or _default_state_path( 130 + work_tree, CONTEXT_LEAF 117 131 ) 118 132 119 133
+322
tartarus/context.py
··· 1 + """Context ledger, selection, and deterministic compaction. 2 + 3 + Sessions remain the provider-native transcript of record. The context layer is a 4 + derived, append-only audit trail plus a selector that decides which messages are 5 + sent to the provider for the next round-trip. 6 + """ 7 + 8 + from __future__ import annotations 9 + 10 + import json 11 + import os 12 + import time 13 + from dataclasses import asdict, dataclass 14 + from typing import Any 15 + 16 + CONTEXT_SUFFIX = ".jsonl" 17 + DEFAULT_CONTEXT_MAX_CHARS = 120_000 18 + DEFAULT_CONTEXT_RECENT_TURNS = 20 19 + SUMMARY_ROLE = "system" 20 + DEFAULT_LEDGER_READ_LIMIT = 20 21 + MAX_LEDGER_READ_LIMIT = 100 22 + SUMMARY_LINE_MAX_CHARS = 240 23 + ELLIPSIS = "..." 24 + CONTEXT_TOOL_NAMES = {"context_status", "context_read"} 25 + CONTEXT_TOOLS = [ 26 + { 27 + "name": "context_status", 28 + "description": "Read the current conversation context status.", 29 + "parameters": { 30 + "type": "object", 31 + "properties": {}, 32 + "additionalProperties": False, 33 + }, 34 + }, 35 + { 36 + "name": "context_read", 37 + "description": "Read recent append-only context ledger events.", 38 + "parameters": { 39 + "type": "object", 40 + "properties": { 41 + "limit": { 42 + "type": "integer", 43 + "minimum": 1, 44 + "maximum": MAX_LEDGER_READ_LIMIT, 45 + "description": "Maximum number of latest ledger events to return.", 46 + } 47 + }, 48 + "additionalProperties": False, 49 + }, 50 + }, 51 + ] 52 + 53 + 54 + class ContextError(Exception): 55 + """Raised when context state cannot be read or written.""" 56 + 57 + 58 + @dataclass(frozen=True) 59 + class ContextLimits: 60 + max_chars: int = DEFAULT_CONTEXT_MAX_CHARS 61 + recent_turns: int = DEFAULT_CONTEXT_RECENT_TURNS 62 + 63 + 64 + @dataclass(frozen=True) 65 + class ContextStatus: 66 + message_count: int 67 + estimated_chars: int 68 + ledger_event_count: int 69 + effective_message_count: int 70 + effective_estimated_chars: int 71 + ledger_path: str | None 72 + 73 + 74 + class ContextLedger: 75 + def __init__(self, context_dir: str, session_id: str): 76 + self._dir = os.path.abspath(context_dir) 77 + self.session_id = session_id 78 + self._path = os.path.join(self._dir, session_id + CONTEXT_SUFFIX) 79 + 80 + @property 81 + def path(self) -> str: 82 + return self._path 83 + 84 + def append_event(self, event: dict[str, Any]) -> None: 85 + if self._dir: 86 + os.makedirs(self._dir, exist_ok=True) 87 + enriched = {"created_at": _timestamp(), **event} 88 + try: 89 + with open(self._path, "a", encoding="utf-8") as context_file: 90 + context_file.write(json.dumps(enriched) + "\n") 91 + except OSError as error: 92 + raise ContextError( 93 + f"cannot write context ledger {self._path}: {error}" 94 + ) from error 95 + 96 + def append_message_events(self, messages: list[dict], start_index: int) -> None: 97 + for offset, message in enumerate(messages[start_index:], start=start_index): 98 + self.append_event(message_event(offset, message)) 99 + 100 + def load_events(self) -> list[dict[str, Any]]: 101 + try: 102 + with open(self._path, encoding="utf-8") as context_file: 103 + return [json.loads(line) for line in context_file if line.strip()] 104 + except FileNotFoundError: 105 + return [] 106 + except (OSError, json.JSONDecodeError) as error: 107 + raise ContextError( 108 + f"cannot read context ledger {self._path}: {error}" 109 + ) from error 110 + 111 + 112 + class ContextManager: 113 + def __init__( 114 + self, 115 + ledger: ContextLedger | None = None, 116 + limits: ContextLimits | None = None, 117 + ): 118 + self._ledger = ledger 119 + self._limits = limits or ContextLimits() 120 + 121 + @property 122 + def ledger_path(self) -> str | None: 123 + return self._ledger.path if self._ledger is not None else None 124 + 125 + def effective_messages(self, messages: list[dict]) -> list[dict]: 126 + events = self._load_events() 127 + summary = latest_summary(events) 128 + if summary is None: 129 + return list(messages) 130 + 131 + # Keep every message after the summarized range; nothing between the 132 + # summary and the recent turns is dropped. fit_to_limit trims oldest 133 + # whole turns only when the result exceeds max_chars. 134 + covered_end = int(summary["covered"]["end"]) 135 + suffix_start = valid_boundary_start(messages, covered_end) 136 + suffix = messages[suffix_start:] 137 + summary_message = { 138 + "role": SUMMARY_ROLE, 139 + "content": "Context summary from earlier transcript:\n\n" 140 + + str(summary["summary"]), 141 + } 142 + return fit_to_limit([summary_message], suffix, self._limits) 143 + 144 + def status(self, messages: list[dict]) -> ContextStatus: 145 + events = self._load_events() 146 + effective = self.effective_messages(messages) 147 + return ContextStatus( 148 + message_count=len(messages), 149 + estimated_chars=estimate_messages(messages), 150 + ledger_event_count=len(events), 151 + effective_message_count=len(effective), 152 + effective_estimated_chars=estimate_messages(effective), 153 + ledger_path=self.ledger_path, 154 + ) 155 + 156 + def read_events( 157 + self, limit: int = DEFAULT_LEDGER_READ_LIMIT 158 + ) -> list[dict[str, Any]]: 159 + events = self._load_events() 160 + bounded_limit = max(1, min(limit, MAX_LEDGER_READ_LIMIT)) 161 + return events[-bounded_limit:] 162 + 163 + def handle_tool( 164 + self, name: str, arguments: dict[str, Any], messages: list[dict] 165 + ) -> str: 166 + if name == "context_status": 167 + return json.dumps(asdict(self.status(messages)), sort_keys=True) 168 + if name == "context_read": 169 + limit = arguments.get("limit", DEFAULT_LEDGER_READ_LIMIT) 170 + if not isinstance(limit, int): 171 + limit = DEFAULT_LEDGER_READ_LIMIT 172 + return json.dumps(self.read_events(limit), sort_keys=True) 173 + raise ContextError(f"unknown context tool '{name}'") 174 + 175 + def compact(self, messages: list[dict]) -> dict[str, Any] | None: 176 + if self._ledger is None: 177 + raise ContextError("cannot compact without a context ledger") 178 + if not messages: 179 + return None 180 + 181 + suffix_start = recent_turn_start(messages, self._limits.recent_turns) 182 + suffix_start = valid_boundary_start(messages, suffix_start) 183 + if suffix_start <= 0: 184 + return None 185 + 186 + summary_text = deterministic_summary(messages[:suffix_start]) 187 + event = { 188 + "type": "context_summary", 189 + "covered": {"start": 0, "end": suffix_start}, 190 + "summary": summary_text, 191 + "source": "deterministic-local", 192 + "estimated_chars": len(summary_text), 193 + } 194 + self._ledger.append_event(event) 195 + return event 196 + 197 + def _load_events(self) -> list[dict[str, Any]]: 198 + if self._ledger is None: 199 + return [] 200 + return self._ledger.load_events() 201 + 202 + 203 + def message_event(index: int, message: dict) -> dict[str, Any]: 204 + role = str(message.get("role", "unknown")) 205 + event_type = { 206 + "user": "user_turn", 207 + "assistant": "assistant_turn", 208 + "tool": "tool_result", 209 + }.get(role, "transcript_message") 210 + if role == "user" and _is_background_notice(message): 211 + event_type = "background_notice" 212 + return { 213 + "type": event_type, 214 + "message_index": index, 215 + "role": role, 216 + "message": message, 217 + "estimated_chars": estimate_message(message), 218 + } 219 + 220 + 221 + def latest_summary(events: list[dict[str, Any]]) -> dict[str, Any] | None: 222 + summaries = [event for event in events if event.get("type") == "context_summary"] 223 + return summaries[-1] if summaries else None 224 + 225 + 226 + def recent_turn_start(messages: list[dict], recent_turns: int) -> int: 227 + if recent_turns <= 0: 228 + return len(messages) 229 + 230 + seen = 0 231 + for index in range(len(messages) - 1, -1, -1): 232 + if messages[index].get("role") == "user": 233 + seen += 1 234 + if seen == recent_turns: 235 + return index 236 + return 0 237 + 238 + 239 + def valid_boundary_start(messages: list[dict], start: int) -> int: 240 + start = max(0, min(start, len(messages))) 241 + while start < len(messages) and messages[start].get("role") == "tool": 242 + start += 1 243 + return start 244 + 245 + 246 + def fit_to_limit( 247 + prefix: list[dict], 248 + suffix: list[dict], 249 + limits: ContextLimits, 250 + ) -> list[dict]: 251 + candidate = list(prefix) + list(suffix) 252 + if estimate_messages(candidate) <= limits.max_chars: 253 + return candidate 254 + 255 + # Over the configured ceiling: drop oldest whole turns from the suffix front 256 + # (each cut starts at a user message), never mid-turn. This is the explicit 257 + # max_chars boundary, not silent loss. 258 + for index, message in enumerate(suffix): 259 + if message.get("role") != "user": 260 + continue 261 + candidate = list(prefix) + suffix[index:] 262 + if estimate_messages(candidate) <= limits.max_chars: 263 + return candidate 264 + return candidate 265 + 266 + 267 + def estimate_message(message: dict) -> int: 268 + return len(json.dumps(message, sort_keys=True)) 269 + 270 + 271 + def estimate_messages(messages: list[dict]) -> int: 272 + return sum(estimate_message(message) for message in messages) 273 + 274 + 275 + def deterministic_summary(messages: list[dict]) -> str: 276 + lines = [ 277 + "# Context Summary", 278 + "", 279 + f"Covered transcript messages: 0-{len(messages)}", 280 + "", 281 + ] 282 + for index, message in enumerate(messages): 283 + role = message.get("role", "unknown") 284 + content = _message_content(message) 285 + if role == "assistant" and message.get("tool_calls"): 286 + lines.append(f"- assistant message {index}: requested tools") 287 + for tool_call in message["tool_calls"]: 288 + function = tool_call.get("function", {}) 289 + name = function.get("name") or tool_call.get("name") or "unknown" 290 + lines.append(f" - tool call: {name}") 291 + continue 292 + if role == "tool": 293 + tool_call_id = message.get("tool_call_id", "unknown") 294 + lines.append( 295 + f"- tool result {index} ({tool_call_id}): {_one_line(content)}" 296 + ) 297 + continue 298 + lines.append(f"- {role} message {index}: {_one_line(content)}") 299 + return "\n".join(lines) 300 + 301 + 302 + def _message_content(message: dict) -> str: 303 + content = message.get("content", "") 304 + if isinstance(content, str): 305 + return content 306 + return json.dumps(content, sort_keys=True) 307 + 308 + 309 + def _one_line(text: str) -> str: 310 + compact = " ".join(text.split()) 311 + if len(compact) > SUMMARY_LINE_MAX_CHARS: 312 + return compact[: SUMMARY_LINE_MAX_CHARS - len(ELLIPSIS)] + ELLIPSIS 313 + return compact 314 + 315 + 316 + def _is_background_notice(message: dict) -> bool: 317 + content = message.get("content") 318 + return isinstance(content, str) and content.startswith("[background] ") 319 + 320 + 321 + def _timestamp() -> str: 322 + return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
+13 -1
tartarus/manifest.py
··· 14 14 from pydantic import BaseModel, Field, field_validator, model_validator 15 15 16 16 from tartarus.constants import CERT_ENV_VARS, STRICT_CONFIG 17 + from tartarus.context import CONTEXT_TOOL_NAMES 17 18 from typing_extensions import Self 18 19 19 20 ··· 139 140 # For kind == "control": which registry operation this tool performs, 140 141 # one of "status" | "output" | "stop". None for every other kind. 141 142 control: Literal["status", "output", "stop"] | None = None 143 + 144 + @field_validator("name") 145 + @classmethod 146 + def _reject_reserved_tool_name(cls, v: str) -> str: 147 + if v in CONTEXT_TOOL_NAMES: 148 + raise ValueError(f"capability name '{v}' is reserved") 149 + return v 142 150 143 151 @field_validator("timeout", mode="before") 144 152 @classmethod ··· 332 340 ) 333 341 upper = key.upper() 334 342 if upper in _RESERVED_SHELL_ENV_NAMES: 335 - raise ValueError(f"shellEnv key '{key}' is reserved and cannot be overridden") 343 + raise ValueError( 344 + f"shellEnv key '{key}' is reserved and cannot be overridden" 345 + ) 336 346 if upper.endswith("_PROXY"): 337 347 raise ValueError( 338 348 f"shellEnv key '{key}' matches the reserved *_PROXY suffix" ··· 358 368 name = tool.get("name") 359 369 if not isinstance(name, str): 360 370 raise ValueError("tool 'name' must be a string") 371 + if name in CONTEXT_TOOL_NAMES: 372 + raise ValueError(f"tool name '{name}' is reserved") 361 373 capability = self.capabilities.get(name) 362 374 if capability is None: 363 375 raise ValueError(f"tool '{name}' has no matching capability")
+13 -3
tartarus/session.py
··· 98 98 self._flushed = len(messages) 99 99 return messages 100 100 101 - def append(self, messages: list[dict]) -> None: 102 - """Persist any messages added since the last flush.""" 101 + @property 102 + def flushed_count(self) -> int: 103 + return self._flushed 104 + 105 + def append(self, messages: list[dict]) -> int | None: 106 + """Persist any messages added since the last flush. 107 + 108 + Returns the first appended message index, or None when there was no new 109 + tail to write. 110 + """ 103 111 tail = messages[self._flushed :] 104 112 if not tail: 105 - return 113 + return None 114 + start_index = self._flushed 106 115 if self._dir: 107 116 os.makedirs(self._dir, exist_ok=True) 108 117 try: ··· 112 121 except OSError as error: 113 122 raise SessionError(f"cannot write session {self._path}: {error}") from error 114 123 self._flushed = len(messages) 124 + return start_index 115 125 116 126 def first_user_message(self) -> str | None: 117 127 """First user-authored text in the session, for listing previews."""
+85
tests/test_agent_loop.py
··· 6 6 7 7 from tartarus.agent_loop import AgentLoop, ToolFinished, ToolStarted 8 8 from tartarus.broker import Broker 9 + from tartarus.context import ContextLedger, ContextLimits, ContextManager 9 10 from tartarus.jail import ExecResult, JailBuilder 10 11 from tartarus.models import ( 11 12 AssistantTurn, ··· 46 47 def __init__(self, turns: list[AssistantTurn]): 47 48 self._turns = list(turns) 48 49 self.received_results: list = [] 50 + self.received_messages: list[list[dict]] = [] 49 51 50 52 async def complete( 51 53 self, system: str, messages: list[dict], tools: list[dict] ··· 55 57 async def stream( 56 58 self, system: str, messages: list[dict], tools: list[dict] 57 59 ) -> AsyncIterator[StreamEvent]: 60 + self.received_messages.append(list(messages)) 58 61 turn = self._turns.pop(0) 59 62 if turn.text: 60 63 yield TextDelta(turn.text) ··· 368 371 assert len(finished) == 1 369 372 assert finished[0].result.is_error 370 373 assert "unknown tool" in finished[0].result.output 374 + 375 + 376 + def test_loop_sends_effective_messages_without_mutating_raw_transcript(tmp_path): 377 + manifest = echo_manifest() 378 + ledger = ContextLedger(str(tmp_path), "s1") 379 + ledger.append_event( 380 + { 381 + "type": "context_summary", 382 + "covered": {"start": 0, "end": 2}, 383 + "summary": "Earlier work was completed.", 384 + "source": "deterministic-local", 385 + "estimated_chars": 29, 386 + } 387 + ) 388 + provider = ScriptedProvider( 389 + [ 390 + AssistantTurn( 391 + text="done", 392 + tool_calls=[], 393 + raw={"role": "assistant"}, 394 + stop_reason="end", 395 + ) 396 + ] 397 + ) 398 + loop = AgentLoop( 399 + provider, 400 + Broker(manifest, cast(JailBuilder, LocalJail()), PolicyEngine()), 401 + manifest, 402 + "system", 403 + ContextManager(ledger, ContextLimits(max_chars=10_000, recent_turns=1)), 404 + ) 405 + messages = [ 406 + {"role": "user", "content": "old"}, 407 + {"role": "assistant", "content": "old reply"}, 408 + {"role": "user", "content": "new"}, 409 + ] 410 + 411 + asyncio.run(_drain(loop, messages)) 412 + 413 + assert provider.received_messages[0][0]["role"] == "system" 414 + assert "Earlier work" in provider.received_messages[0][0]["content"] 415 + assert provider.received_messages[0][1:] == [{"role": "user", "content": "new"}] 416 + assert messages == [ 417 + {"role": "user", "content": "old"}, 418 + {"role": "assistant", "content": "old reply"}, 419 + {"role": "user", "content": "new"}, 420 + {"role": "assistant", "content": "done"}, 421 + ] 422 + 423 + 424 + def test_loop_handles_context_status_as_internal_tool(tmp_path): 425 + manifest = echo_manifest() 426 + provider = ScriptedProvider( 427 + [ 428 + AssistantTurn( 429 + text=None, 430 + tool_calls=[ToolCall("call-1", "context_status", {})], 431 + raw={"role": "assistant"}, 432 + stop_reason="tool_calls", 433 + ), 434 + AssistantTurn( 435 + text="I checked context.", 436 + tool_calls=[], 437 + raw={"role": "assistant"}, 438 + stop_reason="end", 439 + ), 440 + ] 441 + ) 442 + loop = AgentLoop( 443 + provider, 444 + Broker(manifest, cast(JailBuilder, LocalJail()), PolicyEngine()), 445 + manifest, 446 + "system", 447 + ContextManager(ContextLedger(str(tmp_path), "s1")), 448 + ) 449 + messages = [{"role": "user", "content": "status"}] 450 + 451 + events = asyncio.run(_drain(loop, messages)) 452 + 453 + assert _text(events) == "I checked context." 454 + assert len(provider.received_results) == 1 455 + assert '"message_count": 1' in provider.received_results[0].output 371 456 372 457 373 458 def test_loop_brokers_multiple_parallel_tool_calls():
+33 -1
tests/test_cli.py
··· 11 11 _bundle_manifest_source, 12 12 _parse_agent_selector, 13 13 _parse_session_flags, 14 + _print_context_status, 14 15 _run_one_shot, 15 16 ) 16 17 from tartarus.config import ConfigError ··· 76 77 assert flags.list_sessions is True 77 78 78 79 80 + def test_parse_session_flags_context_commands(): 81 + flags, _ = _parse_session_flags(["--context-status"]) 82 + assert flags.context_status is True 83 + flags, _ = _parse_session_flags(["--compact-context"]) 84 + assert flags.compact_context is True 85 + 86 + 79 87 def test_parse_session_flags_resume_without_id_errors(): 80 88 with pytest.raises(ConfigError, match="--resume requires"): 81 89 _parse_session_flags(["--resume"]) ··· 123 131 assert "warning: could not list sessions" in captured.err 124 132 125 133 134 + def test_print_context_status_uses_latest_session_without_api_key( 135 + tmp_path, monkeypatch, capsys 136 + ): 137 + from tartarus.session import SessionStore 138 + 139 + monkeypatch.setenv("TARTARUS_WORK_TREE", str(tmp_path)) 140 + session_dir = tmp_path / ".tartarus" / "sessions" 141 + SessionStore(str(session_dir), "s1").append( 142 + [ 143 + {"role": "user", "content": "hi"}, 144 + {"role": "assistant", "content": "hello"}, 145 + ] 146 + ) 147 + 148 + result = _print_context_status(SessionFlags()) 149 + 150 + captured = capsys.readouterr() 151 + assert result == 0 152 + assert "session: s1" in captured.out 153 + assert "messages: 2" in captured.out 154 + assert "ledger events: 0" in captured.out 155 + 156 + 126 157 def test_run_one_shot_returns_one_when_background_reaction_fails(monkeypatch): 127 158 """A provider-level failure while reacting to a completion yields exit code 1.""" 128 159 ··· 136 167 async def fake_send(_loop, _messages, _text): 137 168 return True 138 169 139 - async def fake_drain(_loop, _messages, _store, _notices): 170 + async def fake_drain(_loop, _messages, _store, _ledger, _notices): 140 171 state["running"] = False 141 172 return False 142 173 ··· 155 186 loop, 156 187 "prompt", 157 188 [], 189 + None, 158 190 None, 159 191 cast(BackgroundRegistry, FakeRegistry()), 160 192 asyncio.Queue(),
+189
tests/test_context.py
··· 1 + import pytest 2 + 3 + from tartarus.context import ( 4 + ContextError, 5 + ContextLedger, 6 + ContextLimits, 7 + ContextManager, 8 + deterministic_summary, 9 + estimate_messages, 10 + message_event, 11 + valid_boundary_start, 12 + ) 13 + from tartarus.session import SessionStore 14 + 15 + 16 + def test_ledger_append_and_load_round_trips_events(tmp_path): 17 + ledger = ContextLedger(str(tmp_path), "s1") 18 + ledger.append_event({"type": "user_turn", "message_index": 0}) 19 + ledger.append_message_events( 20 + [ 21 + {"role": "user", "content": "hi"}, 22 + {"role": "assistant", "content": "hello"}, 23 + ], 24 + 0, 25 + ) 26 + 27 + events = ContextLedger(str(tmp_path), "s1").load_events() 28 + 29 + assert [event["type"] for event in events] == [ 30 + "user_turn", 31 + "user_turn", 32 + "assistant_turn", 33 + ] 34 + assert events[1]["message"]["content"] == "hi" 35 + 36 + 37 + def test_ledger_rejects_corrupt_json(tmp_path): 38 + path = tmp_path / "s1.jsonl" 39 + path.write_text("not json\n") 40 + 41 + with pytest.raises(ContextError, match="cannot read context ledger"): 42 + ContextLedger(str(tmp_path), "s1").load_events() 43 + 44 + 45 + def test_message_event_classifies_tool_and_background_messages(): 46 + assert message_event(0, {"role": "tool", "content": "ok"})["type"] == "tool_result" 47 + event = message_event(1, {"role": "user", "content": "[background] t1 done"}) 48 + assert event["type"] == "background_notice" 49 + 50 + 51 + def test_effective_messages_are_identity_without_summary(tmp_path): 52 + manager = ContextManager(ContextLedger(str(tmp_path), "s1")) 53 + messages = [{"role": "user", "content": "hi"}] 54 + 55 + assert manager.effective_messages(messages) == messages 56 + assert manager.effective_messages(messages) is not messages 57 + 58 + 59 + def test_effective_messages_keep_all_messages_after_the_summary(tmp_path): 60 + # The transcript has grown well past the summarized range; everything after 61 + # covered_end must survive even though it is older than the recent window. 62 + ledger = ContextLedger(str(tmp_path), "s1") 63 + ledger.append_event( 64 + { 65 + "type": "context_summary", 66 + "covered": {"start": 0, "end": 2}, 67 + "summary": "Earlier work was completed.", 68 + "source": "deterministic-local", 69 + "estimated_chars": 27, 70 + } 71 + ) 72 + messages = [ 73 + {"role": "user", "content": "covered 0"}, 74 + {"role": "assistant", "content": "covered 1"}, 75 + {"role": "user", "content": "gap 2"}, 76 + {"role": "assistant", "content": "gap 3"}, 77 + {"role": "user", "content": "recent 4"}, 78 + {"role": "assistant", "content": "recent 5"}, 79 + ] 80 + 81 + effective = ContextManager( 82 + ledger, 83 + ContextLimits(max_chars=10_000, recent_turns=1), 84 + ).effective_messages(messages) 85 + 86 + assert effective[0]["role"] == "system" 87 + assert effective[1:] == messages[2:] 88 + assert messages == [ 89 + {"role": "user", "content": "covered 0"}, 90 + {"role": "assistant", "content": "covered 1"}, 91 + {"role": "user", "content": "gap 2"}, 92 + {"role": "assistant", "content": "gap 3"}, 93 + {"role": "user", "content": "recent 4"}, 94 + {"role": "assistant", "content": "recent 5"}, 95 + ] 96 + 97 + 98 + def test_effective_messages_use_summary_plus_valid_recent_suffix(tmp_path): 99 + ledger = ContextLedger(str(tmp_path), "s1") 100 + ledger.append_event( 101 + { 102 + "type": "context_summary", 103 + "covered": {"start": 0, "end": 2}, 104 + "summary": "Earlier: user asked for setup.", 105 + "source": "deterministic-local", 106 + "estimated_chars": 31, 107 + } 108 + ) 109 + messages = [ 110 + {"role": "user", "content": "old"}, 111 + {"role": "assistant", "content": "old reply"}, 112 + {"role": "user", "content": "new"}, 113 + {"role": "assistant", "content": "new reply"}, 114 + ] 115 + 116 + effective = ContextManager( 117 + ledger, 118 + ContextLimits(max_chars=10_000, recent_turns=1), 119 + ).effective_messages(messages) 120 + 121 + assert effective[0]["role"] == "system" 122 + assert "Earlier: user asked" in effective[0]["content"] 123 + assert effective[1:] == messages[2:] 124 + 125 + 126 + def test_valid_boundary_selection_skips_orphan_tool_results(): 127 + messages = [ 128 + {"role": "user", "content": "run"}, 129 + {"role": "assistant", "tool_calls": [{"id": "call-1"}]}, 130 + {"role": "tool", "tool_call_id": "call-1", "content": "ok"}, 131 + {"role": "assistant", "content": "done"}, 132 + ] 133 + 134 + assert valid_boundary_start(messages, 2) == 3 135 + 136 + 137 + def test_deterministic_compaction_appends_summary_and_preserves_session(tmp_path): 138 + session = SessionStore(str(tmp_path / "sessions"), "s1") 139 + messages = [ 140 + {"role": "user", "content": "old"}, 141 + {"role": "assistant", "content": "old reply"}, 142 + {"role": "user", "content": "new"}, 143 + {"role": "assistant", "content": "new reply"}, 144 + ] 145 + session.append(messages) 146 + ledger = ContextLedger(str(tmp_path / "context"), "s1") 147 + 148 + event = ContextManager( 149 + ledger, 150 + ContextLimits(max_chars=10_000, recent_turns=1), 151 + ).compact(messages) 152 + 153 + assert event is not None 154 + assert event["covered"] == {"start": 0, "end": 2} 155 + assert "user message 0: old" in event["summary"] 156 + assert SessionStore(str(tmp_path / "sessions"), "s1").load() == messages 157 + persisted_event = ContextLedger(str(tmp_path / "context"), "s1").load_events()[-1] 158 + assert persisted_event["type"] == "context_summary" 159 + assert persisted_event["covered"] == {"start": 0, "end": 2} 160 + assert persisted_event["summary"] == event["summary"] 161 + 162 + 163 + def test_status_counts_raw_and_effective_messages(tmp_path): 164 + ledger = ContextLedger(str(tmp_path), "s1") 165 + messages = [{"role": "user", "content": "hi"}] 166 + 167 + status = ContextManager(ledger).status(messages) 168 + 169 + assert status.message_count == 1 170 + assert status.estimated_chars == estimate_messages(messages) 171 + assert status.ledger_event_count == 0 172 + 173 + 174 + def test_deterministic_summary_mentions_tool_calls_and_results(): 175 + summary = deterministic_summary( 176 + [ 177 + {"role": "user", "content": "run it"}, 178 + { 179 + "role": "assistant", 180 + "tool_calls": [ 181 + {"id": "call-1", "function": {"name": "bash"}}, 182 + ], 183 + }, 184 + {"role": "tool", "tool_call_id": "call-1", "content": "ok"}, 185 + ] 186 + ) 187 + 188 + assert "tool call: bash" in summary 189 + assert "tool result 2 (call-1): ok" in summary
+1 -1
tests/test_jail.py
··· 111 111 @_NEEDS_SANDBOX 112 112 def test_shell_hook_runs_before_unrestricted_command(tmp_path, shell_path): 113 113 hook = tmp_path / "hook" 114 - hook.write_text('export HOOK_FLAG=ran\n') 114 + hook.write_text("export HOOK_FLAG=ran\n") 115 115 jail = JailBuilder(str(tmp_path), shell_path) 116 116 spec = JailSpec( 117 117 work_tree=str(tmp_path),
+26
tests/test_manifest.py
··· 1 + import pytest 2 + 1 3 from tartarus.manifest import ( 2 4 Capability, 3 5 Grant, 6 + Manifest, 4 7 Param, 5 8 _RESERVED_SHELL_ENV_NAMES, 6 9 build_manifest, ··· 86 89 tool_names = [tool["name"] for tool in manifest.tools] 87 90 assert tool_names == ["open"] 88 91 assert "locked" in manifest.capabilities 92 + 93 + 94 + @pytest.mark.parametrize("reserved_name", ["context_status", "context_read"]) 95 + def test_reserved_context_capability_names_are_rejected(reserved_name): 96 + with pytest.raises(ValueError, match=f"'{reserved_name}' is reserved"): 97 + Capability( 98 + name=reserved_name, 99 + description="reserved", 100 + policy="auto", 101 + params={}, 102 + grants=Grant(), 103 + runner="true", 104 + ) 105 + 106 + 107 + def test_reserved_context_tool_names_are_rejected_without_capability(): 108 + with pytest.raises(ValueError, match="'context_status' is reserved"): 109 + Manifest( 110 + tools=[{"name": "context_status", "description": "", "parameters": {}}], 111 + capabilities={}, 112 + ca_bundle_file="/nix/store/cacert/etc/ssl/certs/ca-bundle.crt", 113 + shell_closure_file="/nix/store/shell-closure/store-paths", 114 + )
+20
tests/test_manifest_loader.py
··· 278 278 build_manifest_from_raw(raw) 279 279 280 280 281 + @pytest.mark.parametrize("reserved_name", ["context_status", "context_read"]) 282 + def test_reserved_context_capability_name_is_rejected(reserved_name): 283 + raw = _valid_raw() 284 + raw["capabilities"][reserved_name] = raw["capabilities"].pop("echo") 285 + raw["tools"][0]["name"] = reserved_name 286 + 287 + with pytest.raises(ManifestError, match=f"'{reserved_name}' is reserved"): 288 + build_manifest_from_raw(raw) 289 + 290 + 291 + def test_reserved_context_tool_name_without_capability_is_rejected(): 292 + raw = _valid_raw() 293 + raw["tools"].append( 294 + {"name": "context_status", "description": "", "parameters": {}} 295 + ) 296 + 297 + with pytest.raises(ManifestError, match="'context_status' is reserved"): 298 + build_manifest_from_raw(raw) 299 + 300 + 281 301 def test_invalid_policy_literal_is_rejected(): 282 302 raw = _valid_raw() 283 303 raw["capabilities"]["echo"]["policy"] = "sometimes"