···105105 output is forced (reading `config` is free), mirroring `system.build.toplevel`.
106106- Capabilities are keyed attrsets under `capabilities.<name>`. Do not put
107107 `name` in the capability body; the attrset key is the identity.
108108+ `context_status` and `context_read` are reserved internal tool names.
108109- The agent's `shell` is the baseline PATH baked into the manifest; keep it
109110 minimal. Tool-specific programs go in that capability's `grants.packages`.
110111 `shell.env` adds env vars to every call (reserved names rejected at build
+1
README.md
···190190A capability declares:
191191192192- the attrset key as its name, plus `description` and model-facing `params`
193193+ (`context_status` and `context_read` are reserved for internal context tools)
193194- `policy`: `auto`, `ask-once`, `ask-always`, or `deny`
194195- `grants.packages`: package binaries available only to that tool
195196- `grants.network.allowedHosts`: proxy-allowed HTTP(S) hosts
···1515from dataclasses import dataclass
16161717from tartarus.broker import Broker
1818+from tartarus.context import CONTEXT_TOOL_NAMES, CONTEXT_TOOLS, ContextManager
1819from tartarus.manifest import Manifest
1920from tartarus.models import (
2021 TextDelta,
···4849 broker: Broker,
4950 manifest: Manifest,
5051 system_prompt: str,
5252+ context_manager: ContextManager | None = None,
5153 ):
5254 self._provider = provider
5355 self._broker = broker
5456 self._manifest = manifest
5557 self._system_prompt = system_prompt
5858+ self._context_manager = context_manager or ContextManager()
56595760 async def run_turn(self, messages: list[dict]):
5861 """Drive one human turn to completion, yielding UI events as they happen.
···6568 while True:
6669 text_parts: list[str] = []
6770 turn = None
7171+ effective_messages = self._context_manager.effective_messages(messages)
6872 async for event in self._provider.stream(
6969- self._system_prompt, messages, self._manifest.tools
7373+ self._system_prompt, effective_messages, self._tools()
7074 ):
7175 if isinstance(event, TextDelta):
7276 text_parts.append(event.text)
···8690 for call in turn.tool_calls:
8791 yield ToolStarted(call)
8892 result = None
8989- async for tool_event in self._run_tool(call):
9393+ async for tool_event in self._run_tool(call, messages):
9094 if isinstance(tool_event, ToolOutputDelta):
9195 yield tool_event
9296 else:
···100104 messages.append(assistant_message)
101105 messages.extend(self._provider.tool_result_messages(results))
102106103103- async def _run_tool(self, call: ToolCall):
107107+ async def _run_tool(self, call: ToolCall, messages: list[dict]):
108108+ # Context tools only inspect local context state — no host reach to gate —
109109+ # so they answer here directly, bypassing the broker/jail/policy/audit path.
110110+ if call.name in CONTEXT_TOOL_NAMES:
111111+ yield ToolResult(
112112+ call.id,
113113+ self._context_manager.handle_tool(call.name, call.arguments, messages),
114114+ is_error=False,
115115+ )
116116+ return
117117+104118 output_queue: asyncio.Queue[str] = asyncio.Queue()
105119106120 # The broker runs on this loop, so streamed output lands on the queue
···142156 finally:
143157 if pending_get is not None:
144158 pending_get.cancel()
159159+160160+ def _tools(self) -> list[dict]:
161161+ return [*self._manifest.tools, *CONTEXT_TOOLS]
+151-12
tartarus/cli.py
···88"""
991010import asyncio
1111+import os
1112import signal
1213import sys
1314from dataclasses import dataclass
···1617from tartarus.audit import FileAuditLog
1718from tartarus.background import BackgroundRegistry, Notice
1819from tartarus.broker import Broker
2020+from tartarus.bundle import BundleError, base_env_from, load_bundle, resolve_bundle
1921from tartarus.config import (
2022 Config,
2123 ConfigError,
2224 ResolvedRuntime,
2525+ context_dir_from_env,
2326 load_config,
2427 resolve_runtime,
2528 session_dir_from_env,
2629)
2727-from tartarus.bundle import BundleError, base_env_from, load_bundle, resolve_bundle
3030+from tartarus.context import ContextError, ContextLedger, ContextLimits, ContextManager
2831from tartarus.jail import JailBuilder
2932from tartarus.manifest_loader import host_system
3033from tartarus.models import TextDelta, ToolOutputDelta
···4144 continue_latest: bool = False # --continue: reopen the most recent
4245 disabled: bool = False # --no-session: don't persist
4346 list_sessions: bool = False # --list-sessions: print and exit
4747+ context_status: bool = False # --context-status: print status and exit
4848+ compact_context: bool = False # --compact-context: compact current session and exit
444945504651def _parse_session_flags(argv: list[str]) -> tuple[SessionFlags, list[str]]:
···6065 flags.disabled = True
6166 elif arg == "--list-sessions":
6267 flags.list_sessions = True
6868+ elif arg == "--context-status":
6969+ flags.context_status = True
7070+ elif arg == "--compact-context":
7171+ flags.compact_context = True
6372 elif arg == "--resume":
6473 if i + 1 >= len(argv):
6574 raise ConfigError("--resume requires a session id")
···157166 pass
158167159168160160-def _persist(store: SessionStore | None, messages: list[dict]) -> None:
169169+def _persist(
170170+ store: SessionStore | None,
171171+ ledger: ContextLedger | None,
172172+ messages: list[dict],
173173+) -> None:
161174 """Flush newly committed messages, warning (not failing) on write errors."""
162175 if store is None:
163176 return
164177 try:
165165- store.append(messages)
178178+ start_index = store.append(messages)
166179 except SessionError as error:
167180 print(f"warning: could not save session: {error}", file=sys.stderr)
181181+ return
182182+ if start_index is None or ledger is None:
183183+ return
184184+ try:
185185+ ledger.append_message_events(messages, start_index)
186186+ except ContextError as error:
187187+ print(f"warning: could not save context ledger: {error}", file=sys.stderr)
168188169189170190async def _run_one_shot(
···172192 prompt: str,
173193 messages: list[dict],
174194 store: SessionStore | None,
195195+ ledger: ContextLedger | None,
175196 registry: BackgroundRegistry,
176197 notices: "asyncio.Queue[Notice]",
177198) -> int:
178199 try:
179200 if await _send(loop, messages, prompt):
180180- _persist(store, messages)
201201+ _persist(store, ledger, messages)
181202 # A one-shot run that launched background work waits it out, reacting to
182203 # each completion, so the task is not killed the instant the turn ends.
183204 failed = False
184205 while registry.has_running or not notices.empty():
185185- if not await _drain_notice(loop, messages, store, notices):
206206+ if not await _drain_notice(loop, messages, store, ledger, notices):
186207 failed = True
187208 return 1 if failed else 0
188209 except ProviderError as error:
···194215 loop: AgentLoop,
195216 messages: list[dict],
196217 store: SessionStore | None,
218218+ ledger: ContextLedger | None,
197219 registry: BackgroundRegistry,
198220 notices: "asyncio.Queue[Notice]",
199221) -> int:
···218240 continue
219241220242 if notice_task in done:
221221- await _react_to_notice(loop, messages, store, notice_task.result())
243243+ await _react_to_notice(loop, messages, store, ledger, notice_task.result())
222244 continue
223245224246 # notice_task did not win, so the input task is the one that completed.
···234256 continue
235257 try:
236258 if await _send(loop, messages, user_text):
237237- _persist(store, messages)
259259+ _persist(store, ledger, messages)
238260 except ProviderError as error:
239261 print(f"provider error: {error}", file=sys.stderr)
240262···247269 loop: AgentLoop,
248270 messages: list[dict],
249271 store: SessionStore | None,
272272+ ledger: ContextLedger | None,
250273 notices: "asyncio.Queue[Notice]",
251274) -> bool:
252252- return await _react_to_notice(loop, messages, store, await notices.get())
275275+ return await _react_to_notice(loop, messages, store, ledger, await notices.get())
253276254277255278async def _react_to_notice(
256279 loop: AgentLoop,
257280 messages: list[dict],
258281 store: SessionStore | None,
282282+ ledger: ContextLedger | None,
259283 notice: Notice,
260284) -> bool:
261285 """Turn one background completion into a transcript message + follow-up turn.
···276300 print(f"\n [background] {notice.task_id} finished (exit {notice.exit_code})")
277301 try:
278302 if await _send(loop, messages, text):
279279- _persist(store, messages)
303303+ _persist(store, ledger, messages)
280304 return True
281305 return False
282306 except ProviderError as error:
···308332 print(f"{session_id} {preview}")
309333310334335335+def _context_limits(max_chars: int | None, recent_turns: int | None) -> ContextLimits:
336336+ """Validate context limits, falling back to defaults for unset (None) values.
337337+338338+ The single resolution point for both the env-only inspection path and the
339339+ config-driven live run, so they cannot validate differently.
340340+ """
341341+ defaults = ContextLimits()
342342+ resolved_max_chars = max_chars if max_chars is not None else defaults.max_chars
343343+ resolved_recent_turns = (
344344+ recent_turns if recent_turns is not None else defaults.recent_turns
345345+ )
346346+ if resolved_max_chars < 0:
347347+ raise ConfigError("TARTARUS_CONTEXT_MAX_CHARS must be non-negative")
348348+ if resolved_recent_turns < 0:
349349+ raise ConfigError("TARTARUS_CONTEXT_RECENT_TURNS must be non-negative")
350350+ return ContextLimits(
351351+ max_chars=resolved_max_chars,
352352+ recent_turns=resolved_recent_turns,
353353+ )
354354+355355+356356+def _context_limits_from_env() -> ContextLimits:
357357+ return _context_limits(
358358+ _int_from_env("TARTARUS_CONTEXT_MAX_CHARS", None),
359359+ _int_from_env("TARTARUS_CONTEXT_RECENT_TURNS", None),
360360+ )
361361+362362+363363+def _context_limits_from_config(config: Config) -> ContextLimits:
364364+ return _context_limits(config.context_max_chars, config.context_recent_turns)
365365+366366+367367+def _int_from_env(name: str, default: int | None) -> int | None:
368368+ """Parse an integer env var, or return the default when unset.
369369+370370+ Range validation lives in _context_limits, the single resolution point; this
371371+ only turns the raw string into an int.
372372+ """
373373+ value = os.environ.get(name)
374374+ if value is None or value == "":
375375+ return default
376376+ try:
377377+ return int(value)
378378+ except ValueError as error:
379379+ raise ConfigError(f"{name} must be an integer") from error
380380+381381+382382+def _resolve_read_only_session(flags: SessionFlags) -> tuple[SessionStore, list[dict]]:
383383+ session_dir = session_dir_from_env()
384384+ if flags.disabled:
385385+ raise SessionError("--no-session cannot be combined with context inspection")
386386+ if flags.resume is not None:
387387+ session_id = SessionStore.resolve(session_dir, flags.resume)
388388+ else:
389389+ session_id = SessionStore.latest(session_dir)
390390+ if session_id is None:
391391+ raise SessionError(f"no sessions in {session_dir}")
392392+ store = SessionStore(session_dir, session_id)
393393+ return store, store.load()
394394+395395+396396+def _print_context_status(flags: SessionFlags) -> int:
397397+ try:
398398+ store, messages = _resolve_read_only_session(flags)
399399+ ledger = ContextLedger(context_dir_from_env(), store.session_id)
400400+ manager = ContextManager(ledger, _context_limits_from_env())
401401+ status = manager.status(messages)
402402+ except (ConfigError, ContextError, SessionError) as error:
403403+ print(f"configuration error: {error}", file=sys.stderr)
404404+ return 1
405405+ print(f"session: {store.session_id}")
406406+ print(f"messages: {status.message_count}")
407407+ print(f"estimated context chars: {status.estimated_chars}")
408408+ print(f"effective messages: {status.effective_message_count}")
409409+ print(f"effective estimated chars: {status.effective_estimated_chars}")
410410+ print(f"ledger events: {status.ledger_event_count}")
411411+ print(f"ledger: {status.ledger_path}")
412412+ return 0
413413+414414+415415+def _compact_context(flags: SessionFlags) -> int:
416416+ try:
417417+ store, messages = _resolve_read_only_session(flags)
418418+ ledger = ContextLedger(context_dir_from_env(), store.session_id)
419419+ event = ContextManager(ledger, _context_limits_from_env()).compact(messages)
420420+ except (ConfigError, ContextError, SessionError) as error:
421421+ print(f"configuration error: {error}", file=sys.stderr)
422422+ return 1
423423+ if event is None:
424424+ print(f"session: {store.session_id}")
425425+ print("compaction: nothing to compact")
426426+ print(f"ledger: {ledger.path}")
427427+ return 0
428428+ covered = event["covered"]
429429+ print(f"session: {store.session_id}")
430430+ print(f"compacted messages: {covered['start']}-{covered['end']}")
431431+ print(f"ledger: {ledger.path}")
432432+ return 0
433433+434434+311435def _open_session(
312436 config: Config, flags: SessionFlags
313437) -> tuple[SessionStore | None, list[dict]]:
···345469 if session_flags.list_sessions:
346470 _print_session_list(session_dir_from_env())
347471 return 0
472472+ if session_flags.context_status:
473473+ return _print_context_status(session_flags)
474474+ if session_flags.compact_context:
475475+ return _compact_context(session_flags)
348476349477 try:
350478 config = load_config()
···355483 except (ConfigError, SessionError) as error:
356484 print(f"configuration error: {error}", file=sys.stderr)
357485 return 1
486486+487487+ ledger = (
488488+ ContextLedger(config.context_dir, store.session_id)
489489+ if store is not None
490490+ else None
491491+ )
492492+ context_manager = ContextManager(ledger, _context_limits_from_config(config))
358493359494 print("loading agent bundle...", file=sys.stderr)
360495 try:
···406541 )
407542 # The agent's Nix definition owns its persona; the config default is a fallback.
408543 system_prompt = manifest.system_prompt or config.system_prompt
409409- loop = AgentLoop(provider, broker, manifest, system_prompt)
544544+ loop = AgentLoop(provider, broker, manifest, system_prompt, context_manager)
410545411546 tool_names = ", ".join(tool["name"] for tool in manifest.tools)
412547 mode = "headless" if config.headless else "interactive"
···421556 print(f"audit log: {config.audit_path}", file=sys.stderr)
422557 if store is not None:
423558 print(f"session: {store.session_id} ({store.path})", file=sys.stderr)
559559+ if ledger is not None:
560560+ print(f"context ledger: {ledger.path}", file=sys.stderr)
424561425562 prompt = " ".join(argv).strip()
426563 try:
427564 if prompt:
428428- return await _run_one_shot(loop, prompt, messages, store, registry, notices)
429429- return await _run_repl(loop, messages, store, registry, notices)
565565+ return await _run_one_shot(
566566+ loop, prompt, messages, store, ledger, registry, notices
567567+ )
568568+ return await _run_repl(loop, messages, store, ledger, registry, notices)
430569 finally:
431570 registry.shutdown_all()
432571
+14
tartarus/config.py
···3232# Leaf names under <work_tree>/.tartarus, shared by every path-deriving call site.
3333AUDIT_LOG_LEAF = "audit.jsonl"
3434SESSIONS_LEAF = "sessions"
3535+CONTEXT_LEAF = "context"
3536# `path:` copies the directory regardless of git tracking, which keeps local
3637# capability edits visible before they are committed.
3738DEFAULT_FLAKE_REF = "path:."
···9293 audit_path: str = ""
9394 # Directory holding per-conversation transcript files (<id>.jsonl).
9495 session_dir: str = Field("", validation_alias=AliasChoices("TARTARUS_SESSIONS_DIR"))
9696+ # Directory holding append-only per-session context ledgers.
9797+ context_dir: str = Field("", validation_alias=AliasChoices("TARTARUS_CONTEXT_DIR"))
9898+ context_max_chars: int | None = None
9999+ context_recent_turns: int | None = None
95100 output_truncate: int = DEFAULT_OUTPUT_TRUNCATE_CHARS
9610197102 @model_validator(mode="after")
···102107 self.audit_path = _default_state_path(self.work_tree, AUDIT_LOG_LEAF)
103108 if not self.session_dir:
104109 self.session_dir = _default_state_path(self.work_tree, SESSIONS_LEAF)
110110+ if not self.context_dir:
111111+ self.context_dir = _default_state_path(self.work_tree, CONTEXT_LEAF)
105112 return self
106113107114···114121 work_tree = os.environ.get("TARTARUS_WORK_TREE") or os.getcwd()
115122 return os.environ.get("TARTARUS_SESSIONS_DIR") or _default_state_path(
116123 work_tree, SESSIONS_LEAF
124124+ )
125125+126126+127127+def context_dir_from_env() -> str:
128128+ work_tree = os.environ.get("TARTARUS_WORK_TREE") or os.getcwd()
129129+ return os.environ.get("TARTARUS_CONTEXT_DIR") or _default_state_path(
130130+ work_tree, CONTEXT_LEAF
117131 )
118132119133
+322
tartarus/context.py
···11+"""Context ledger, selection, and deterministic compaction.
22+33+Sessions remain the provider-native transcript of record. The context layer is a
44+derived, append-only audit trail plus a selector that decides which messages are
55+sent to the provider for the next round-trip.
66+"""
77+88+from __future__ import annotations
99+1010+import json
1111+import os
1212+import time
1313+from dataclasses import asdict, dataclass
1414+from typing import Any
1515+1616+CONTEXT_SUFFIX = ".jsonl"
1717+DEFAULT_CONTEXT_MAX_CHARS = 120_000
1818+DEFAULT_CONTEXT_RECENT_TURNS = 20
1919+SUMMARY_ROLE = "system"
2020+DEFAULT_LEDGER_READ_LIMIT = 20
2121+MAX_LEDGER_READ_LIMIT = 100
2222+SUMMARY_LINE_MAX_CHARS = 240
2323+ELLIPSIS = "..."
2424+CONTEXT_TOOL_NAMES = {"context_status", "context_read"}
2525+CONTEXT_TOOLS = [
2626+ {
2727+ "name": "context_status",
2828+ "description": "Read the current conversation context status.",
2929+ "parameters": {
3030+ "type": "object",
3131+ "properties": {},
3232+ "additionalProperties": False,
3333+ },
3434+ },
3535+ {
3636+ "name": "context_read",
3737+ "description": "Read recent append-only context ledger events.",
3838+ "parameters": {
3939+ "type": "object",
4040+ "properties": {
4141+ "limit": {
4242+ "type": "integer",
4343+ "minimum": 1,
4444+ "maximum": MAX_LEDGER_READ_LIMIT,
4545+ "description": "Maximum number of latest ledger events to return.",
4646+ }
4747+ },
4848+ "additionalProperties": False,
4949+ },
5050+ },
5151+]
5252+5353+5454+class ContextError(Exception):
5555+ """Raised when context state cannot be read or written."""
5656+5757+5858+@dataclass(frozen=True)
5959+class ContextLimits:
6060+ max_chars: int = DEFAULT_CONTEXT_MAX_CHARS
6161+ recent_turns: int = DEFAULT_CONTEXT_RECENT_TURNS
6262+6363+6464+@dataclass(frozen=True)
6565+class ContextStatus:
6666+ message_count: int
6767+ estimated_chars: int
6868+ ledger_event_count: int
6969+ effective_message_count: int
7070+ effective_estimated_chars: int
7171+ ledger_path: str | None
7272+7373+7474+class ContextLedger:
7575+ def __init__(self, context_dir: str, session_id: str):
7676+ self._dir = os.path.abspath(context_dir)
7777+ self.session_id = session_id
7878+ self._path = os.path.join(self._dir, session_id + CONTEXT_SUFFIX)
7979+8080+ @property
8181+ def path(self) -> str:
8282+ return self._path
8383+8484+ def append_event(self, event: dict[str, Any]) -> None:
8585+ if self._dir:
8686+ os.makedirs(self._dir, exist_ok=True)
8787+ enriched = {"created_at": _timestamp(), **event}
8888+ try:
8989+ with open(self._path, "a", encoding="utf-8") as context_file:
9090+ context_file.write(json.dumps(enriched) + "\n")
9191+ except OSError as error:
9292+ raise ContextError(
9393+ f"cannot write context ledger {self._path}: {error}"
9494+ ) from error
9595+9696+ def append_message_events(self, messages: list[dict], start_index: int) -> None:
9797+ for offset, message in enumerate(messages[start_index:], start=start_index):
9898+ self.append_event(message_event(offset, message))
9999+100100+ def load_events(self) -> list[dict[str, Any]]:
101101+ try:
102102+ with open(self._path, encoding="utf-8") as context_file:
103103+ return [json.loads(line) for line in context_file if line.strip()]
104104+ except FileNotFoundError:
105105+ return []
106106+ except (OSError, json.JSONDecodeError) as error:
107107+ raise ContextError(
108108+ f"cannot read context ledger {self._path}: {error}"
109109+ ) from error
110110+111111+112112+class ContextManager:
113113+ def __init__(
114114+ self,
115115+ ledger: ContextLedger | None = None,
116116+ limits: ContextLimits | None = None,
117117+ ):
118118+ self._ledger = ledger
119119+ self._limits = limits or ContextLimits()
120120+121121+ @property
122122+ def ledger_path(self) -> str | None:
123123+ return self._ledger.path if self._ledger is not None else None
124124+125125+ def effective_messages(self, messages: list[dict]) -> list[dict]:
126126+ events = self._load_events()
127127+ summary = latest_summary(events)
128128+ if summary is None:
129129+ return list(messages)
130130+131131+ # Keep every message after the summarized range; nothing between the
132132+ # summary and the recent turns is dropped. fit_to_limit trims oldest
133133+ # whole turns only when the result exceeds max_chars.
134134+ covered_end = int(summary["covered"]["end"])
135135+ suffix_start = valid_boundary_start(messages, covered_end)
136136+ suffix = messages[suffix_start:]
137137+ summary_message = {
138138+ "role": SUMMARY_ROLE,
139139+ "content": "Context summary from earlier transcript:\n\n"
140140+ + str(summary["summary"]),
141141+ }
142142+ return fit_to_limit([summary_message], suffix, self._limits)
143143+144144+ def status(self, messages: list[dict]) -> ContextStatus:
145145+ events = self._load_events()
146146+ effective = self.effective_messages(messages)
147147+ return ContextStatus(
148148+ message_count=len(messages),
149149+ estimated_chars=estimate_messages(messages),
150150+ ledger_event_count=len(events),
151151+ effective_message_count=len(effective),
152152+ effective_estimated_chars=estimate_messages(effective),
153153+ ledger_path=self.ledger_path,
154154+ )
155155+156156+ def read_events(
157157+ self, limit: int = DEFAULT_LEDGER_READ_LIMIT
158158+ ) -> list[dict[str, Any]]:
159159+ events = self._load_events()
160160+ bounded_limit = max(1, min(limit, MAX_LEDGER_READ_LIMIT))
161161+ return events[-bounded_limit:]
162162+163163+ def handle_tool(
164164+ self, name: str, arguments: dict[str, Any], messages: list[dict]
165165+ ) -> str:
166166+ if name == "context_status":
167167+ return json.dumps(asdict(self.status(messages)), sort_keys=True)
168168+ if name == "context_read":
169169+ limit = arguments.get("limit", DEFAULT_LEDGER_READ_LIMIT)
170170+ if not isinstance(limit, int):
171171+ limit = DEFAULT_LEDGER_READ_LIMIT
172172+ return json.dumps(self.read_events(limit), sort_keys=True)
173173+ raise ContextError(f"unknown context tool '{name}'")
174174+175175+ def compact(self, messages: list[dict]) -> dict[str, Any] | None:
176176+ if self._ledger is None:
177177+ raise ContextError("cannot compact without a context ledger")
178178+ if not messages:
179179+ return None
180180+181181+ suffix_start = recent_turn_start(messages, self._limits.recent_turns)
182182+ suffix_start = valid_boundary_start(messages, suffix_start)
183183+ if suffix_start <= 0:
184184+ return None
185185+186186+ summary_text = deterministic_summary(messages[:suffix_start])
187187+ event = {
188188+ "type": "context_summary",
189189+ "covered": {"start": 0, "end": suffix_start},
190190+ "summary": summary_text,
191191+ "source": "deterministic-local",
192192+ "estimated_chars": len(summary_text),
193193+ }
194194+ self._ledger.append_event(event)
195195+ return event
196196+197197+ def _load_events(self) -> list[dict[str, Any]]:
198198+ if self._ledger is None:
199199+ return []
200200+ return self._ledger.load_events()
201201+202202+203203+def message_event(index: int, message: dict) -> dict[str, Any]:
204204+ role = str(message.get("role", "unknown"))
205205+ event_type = {
206206+ "user": "user_turn",
207207+ "assistant": "assistant_turn",
208208+ "tool": "tool_result",
209209+ }.get(role, "transcript_message")
210210+ if role == "user" and _is_background_notice(message):
211211+ event_type = "background_notice"
212212+ return {
213213+ "type": event_type,
214214+ "message_index": index,
215215+ "role": role,
216216+ "message": message,
217217+ "estimated_chars": estimate_message(message),
218218+ }
219219+220220+221221+def latest_summary(events: list[dict[str, Any]]) -> dict[str, Any] | None:
222222+ summaries = [event for event in events if event.get("type") == "context_summary"]
223223+ return summaries[-1] if summaries else None
224224+225225+226226+def recent_turn_start(messages: list[dict], recent_turns: int) -> int:
227227+ if recent_turns <= 0:
228228+ return len(messages)
229229+230230+ seen = 0
231231+ for index in range(len(messages) - 1, -1, -1):
232232+ if messages[index].get("role") == "user":
233233+ seen += 1
234234+ if seen == recent_turns:
235235+ return index
236236+ return 0
237237+238238+239239+def valid_boundary_start(messages: list[dict], start: int) -> int:
240240+ start = max(0, min(start, len(messages)))
241241+ while start < len(messages) and messages[start].get("role") == "tool":
242242+ start += 1
243243+ return start
244244+245245+246246+def fit_to_limit(
247247+ prefix: list[dict],
248248+ suffix: list[dict],
249249+ limits: ContextLimits,
250250+) -> list[dict]:
251251+ candidate = list(prefix) + list(suffix)
252252+ if estimate_messages(candidate) <= limits.max_chars:
253253+ return candidate
254254+255255+ # Over the configured ceiling: drop oldest whole turns from the suffix front
256256+ # (each cut starts at a user message), never mid-turn. This is the explicit
257257+ # max_chars boundary, not silent loss.
258258+ for index, message in enumerate(suffix):
259259+ if message.get("role") != "user":
260260+ continue
261261+ candidate = list(prefix) + suffix[index:]
262262+ if estimate_messages(candidate) <= limits.max_chars:
263263+ return candidate
264264+ return candidate
265265+266266+267267+def estimate_message(message: dict) -> int:
268268+ return len(json.dumps(message, sort_keys=True))
269269+270270+271271+def estimate_messages(messages: list[dict]) -> int:
272272+ return sum(estimate_message(message) for message in messages)
273273+274274+275275+def deterministic_summary(messages: list[dict]) -> str:
276276+ lines = [
277277+ "# Context Summary",
278278+ "",
279279+ f"Covered transcript messages: 0-{len(messages)}",
280280+ "",
281281+ ]
282282+ for index, message in enumerate(messages):
283283+ role = message.get("role", "unknown")
284284+ content = _message_content(message)
285285+ if role == "assistant" and message.get("tool_calls"):
286286+ lines.append(f"- assistant message {index}: requested tools")
287287+ for tool_call in message["tool_calls"]:
288288+ function = tool_call.get("function", {})
289289+ name = function.get("name") or tool_call.get("name") or "unknown"
290290+ lines.append(f" - tool call: {name}")
291291+ continue
292292+ if role == "tool":
293293+ tool_call_id = message.get("tool_call_id", "unknown")
294294+ lines.append(
295295+ f"- tool result {index} ({tool_call_id}): {_one_line(content)}"
296296+ )
297297+ continue
298298+ lines.append(f"- {role} message {index}: {_one_line(content)}")
299299+ return "\n".join(lines)
300300+301301+302302+def _message_content(message: dict) -> str:
303303+ content = message.get("content", "")
304304+ if isinstance(content, str):
305305+ return content
306306+ return json.dumps(content, sort_keys=True)
307307+308308+309309+def _one_line(text: str) -> str:
310310+ compact = " ".join(text.split())
311311+ if len(compact) > SUMMARY_LINE_MAX_CHARS:
312312+ return compact[: SUMMARY_LINE_MAX_CHARS - len(ELLIPSIS)] + ELLIPSIS
313313+ return compact
314314+315315+316316+def _is_background_notice(message: dict) -> bool:
317317+ content = message.get("content")
318318+ return isinstance(content, str) and content.startswith("[background] ")
319319+320320+321321+def _timestamp() -> str:
322322+ return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
+13-1
tartarus/manifest.py
···1414from pydantic import BaseModel, Field, field_validator, model_validator
15151616from tartarus.constants import CERT_ENV_VARS, STRICT_CONFIG
1717+from tartarus.context import CONTEXT_TOOL_NAMES
1718from typing_extensions import Self
18191920···139140 # For kind == "control": which registry operation this tool performs,
140141 # one of "status" | "output" | "stop". None for every other kind.
141142 control: Literal["status", "output", "stop"] | None = None
143143+144144+ @field_validator("name")
145145+ @classmethod
146146+ def _reject_reserved_tool_name(cls, v: str) -> str:
147147+ if v in CONTEXT_TOOL_NAMES:
148148+ raise ValueError(f"capability name '{v}' is reserved")
149149+ return v
142150143151 @field_validator("timeout", mode="before")
144152 @classmethod
···332340 )
333341 upper = key.upper()
334342 if upper in _RESERVED_SHELL_ENV_NAMES:
335335- raise ValueError(f"shellEnv key '{key}' is reserved and cannot be overridden")
343343+ raise ValueError(
344344+ f"shellEnv key '{key}' is reserved and cannot be overridden"
345345+ )
336346 if upper.endswith("_PROXY"):
337347 raise ValueError(
338348 f"shellEnv key '{key}' matches the reserved *_PROXY suffix"
···358368 name = tool.get("name")
359369 if not isinstance(name, str):
360370 raise ValueError("tool 'name' must be a string")
371371+ if name in CONTEXT_TOOL_NAMES:
372372+ raise ValueError(f"tool name '{name}' is reserved")
361373 capability = self.capabilities.get(name)
362374 if capability is None:
363375 raise ValueError(f"tool '{name}' has no matching capability")
+13-3
tartarus/session.py
···9898 self._flushed = len(messages)
9999 return messages
100100101101- def append(self, messages: list[dict]) -> None:
102102- """Persist any messages added since the last flush."""
101101+ @property
102102+ def flushed_count(self) -> int:
103103+ return self._flushed
104104+105105+ def append(self, messages: list[dict]) -> int | None:
106106+ """Persist any messages added since the last flush.
107107+108108+ Returns the first appended message index, or None when there was no new
109109+ tail to write.
110110+ """
103111 tail = messages[self._flushed :]
104112 if not tail:
105105- return
113113+ return None
114114+ start_index = self._flushed
106115 if self._dir:
107116 os.makedirs(self._dir, exist_ok=True)
108117 try:
···112121 except OSError as error:
113122 raise SessionError(f"cannot write session {self._path}: {error}") from error
114123 self._flushed = len(messages)
124124+ return start_index
115125116126 def first_user_message(self) -> str | None:
117127 """First user-authored text in the session, for listing previews."""