Skip to content

Sessions and the conversation tree

AgentSession

class AgentSession(session_log: SessionLog, model: Model, system_prompt: str = '', tools: list[AgentTool] | None = None, extensions: list[Callable] | None = None, api_key: str | None = None, reasoning: str | None = None, compaction_settings: CompactionSettings | None = None, compaction_policy: CompactionPolicy | None = None, extensions_config: dict[str, dict[str, Any]] | None = None, model_resolver: Callable[[str], Model] | None = None, max_turns: int | None = None, tool_execution_mode: Literal['sequential', 'parallel'] = 'parallel', bus_available: bool = False, no_tools: Literal['all', 'builtin'] | None = None)

tau_agent_core.agent_session.AgentSession

High-level session API. Combines agent loop, a session log, and events.

This is the primary entry point for both SDK and TUI usage.

Persistence goes through a :class:~tau_agent_core.session_log.SessionLog (the coding-agent's file Session on the live path, an :class:~tau_agent_core.session_log.InMemorySessionLog on the SDK default path); context is rebuilt from the log's entries + cursor via :class:~tau_agent_core.conversation_tree.ConversationTree — the retired System-A SessionManager no longer participates (§2.6).

Constructor parameters

  • session_log: SessionLog — (no description)
  • model: Model — (no description)
  • system_prompt: str = '' — (no description)
  • tools: list[AgentTool] | None = None — (no description)
  • extensions: list[Callable] | None = None — (no description)
  • api_key: str | None = None — (no description)
  • reasoning: str | None = None — (no description)
  • compaction_settings: CompactionSettings | None = None — (no description)
  • compaction_policy: CompactionPolicy | None = None — (no description)
  • extensions_config: dict[str, dict[str, Any]] | None = None — (no description)
  • model_resolver: Callable[[str], Model] | None = None — (no description)
  • max_turns: int | None = None — (no description)
  • tool_execution_mode: Literal['sequential', 'parallel'] = 'parallel' — (no description)
  • bus_available: bool = False — (no description)
  • no_tools: Literal['all', 'builtin'] | None = None — (no description)

abort

abort() -> None

tau_agent_core.agent_session.AgentSession.abort

Abort the current agent turn, and every still-running forked branch.

A multitask_strategy="fork" submission's second agent is a REAL AgentSession, but not one an abort() caller has a handle to — it was constructed and awaited from inside :meth:_spawn_fork's background task. Cancelling that asyncio.Task is this session's own "abort path" reaching it (docs/SUBMISSION-LIFECYCLE.md "fork" — "must be cancellable via the session's abort path"): the forked branch's own AgentSession does not need — and does not get — a direct abort() call, because cancelling its driving task raises CancelledError into whichever await it is suspended on, unwinding it the same way any cancelled coroutine unwinds.

:attr:_threadsafe_tasks is deliberately NOT cancelled here, and the asymmetry is the point. A fork is a second agent that abort() has no other handle on; a marshalled submission is an ORDINARY turn on this session — if it is the one in flight, the signal above already aborts it, by the same mechanism as any other turn. Cancelling the rest would discard submissions that have not been admitted yet, i.e. input this abort was never about, arriving from a source the aborting user cannot see. They are drained at session shutdown instead (:meth:emit_session_shutdown).

compact

compact(custom_instructions: str | None = None) -> CompactionResult | None

tau_agent_core.agent_session.AgentSession.compact

Compact the active conversation into an LLM-generated summary.

Runs the full pipeline — build the active-path entries (ConversationTree.context_entries), choose the cut point, generate the structured summary via the LLM (:func:tau_agent_core.compaction.compact), and record the boundary by APPENDING a compaction entry (SessionLog.append_compaction, append-only) so the compacted prefix drops out of future context at read time. agent_start / agent_end bracket the work for subscribers (e.g. the TUI).

Parameters

  • custom_instructions: str | None = None — Optional extra focus for the summary.

Returns

The CompactionResult, or None when there is nothing to compact (an empty conversation, or one already ending in a compaction summary).

Raises

  • CompactionError — if summary generation fails. Fail-Early — no fabricated summary is written.

compact_messages

compact_messages(messages: list[dict[str, Any]], custom_instructions: str | None = None) -> list[dict[str, Any]] | None

tau_agent_core.agent_session.AgentSession.compact_messages

Compact a caller-supplied message list and return the shortened list.

This is the manual compaction path (the TUI's /compact): it summarizes everything before the most recent user turn and keeps that turn intact, returning a new list shaped [<system messages>, <summary as a user message>, <most recent user turn onward>]. It is for callers whose own store — not the session manager — is the authoritative context they send to the model (the TUI's current_chat.messages is exactly this).

The cut is count-based (keep the last user turn), deliberately unlike auto-compaction's token-budget cut: a manual compaction should visibly do something on a normal-sized chat, not no-op until the conversation exceeds keep_recent_tokens.

Returns None when there is nothing older to compact (zero or one user turn), so the caller can no-op rather than grow the list with an empty summary.

Parameters

  • messages: list[dict[str, Any]] — (no description)
  • custom_instructions: str | None = None — (no description)

Raises

  • CompactionError — if summary generation fails. Fail-Early.

continue_conversation

continue_conversation() -> list[dict[str, Any]]

tau_agent_core.agent_session.AgentSession.continue_conversation

Run another agent turn without adding new messages.

Delegates to AgentLoop.run_continue() which streams the LLM response via stream_simple() and handles tool calls.

Concurrency guard (docs/SUBMISSION-LIFECYCLE.md "two unguarded doors" — this method sets _is_streaming = True with no check, exactly as prompt() did before submit() existed): this is the SAME in-flight-turn admission :meth:submit applies for multitask_strategy="reject". It has no Submission and no SubmissionResult to carry a refusal through — it predates that contract — so the refusal that would be accepted=False there is a raise here instead of a silently-corrupting concurrent run.

Returns

List of messages produced by the agent loop.

Raises

  • RuntimeError — a turn is already in flight on this session.

disable_extension

disable_extension(path: str) -> ExtensionActionResult

tau_agent_core.agent_session.AgentSession.disable_extension

Tear down and detach a loaded extension so its hooks stop firing (S70).

Fires the extension's own session_shutdown (reason "disable") FIRST — the S41 teardown seam, so a watcher/exit-commit runs cleanly — then removes its runner bucket (hooks stop) and unwinds its registry tools/commands/shortcuts. The LoadedExtension record is KEPT so :meth:enable_extension can bring it back. A no-op (unknown / already disabled) returns ok=False, not an error.

Parameters

  • path: str — (no description)

emit_session_shutdown

emit_session_shutdown(reason: str = 'quit') -> None

tau_agent_core.agent_session.AgentSession.emit_session_shutdown

Fire the notify-grade session_shutdown lifecycle hook (S41), and drain every still-running forked branch and marshalled submission.

The teardown counterpart to :meth:emit_session_start, dispatched through the runner (error-surfaced, not swallowed). The frontends fire it on the genuine end-of-runtime moments — TUI quit, headless completion, and SIGINT/SIGTERM — so an extension can commit exit state / stop watchers.

This is also where the supervised fork-task registry closes out (docs/SUBMISSION-LIFECYCLE.md "fork" — "must not become an orphan on session close", :meth:_cancel_forked_tasks): run UNCONDITIONALLY, before the has_handlers fast path below, because a forked branch must not survive session teardown regardless of whether any extension happens to be listening for it.

The same closes out :attr:_threadsafe_tasks (phase 4): a submission marshalled in from a bus thread a millisecond before quit has no awaiting caller either, and the reason it must not outlive the session is identical.

reason mirrors pi's SessionShutdownEvent.reason ("quit" | "reload" | "new" | "resume" | "fork").

Parameters

  • reason: str = 'quit' — (no description)

emit_session_start

emit_session_start(reason: str = 'startup') -> None

tau_agent_core.agent_session.AgentSession.emit_session_start

Fire the notify-grade session_start lifecycle hook (S41).

Dispatched through the session's :class:ExtensionRunner (not the notify EventBus) so a handler's exception is SURFACED (S44), not swallowed. Called by the frontends after extensions are loaded — so a session_start handler can reconstruct state from ctx.entries() / install watchers with its registration already in place. Returns nothing: the hook has no path effect. Gated on has_handlers for the zero-extension fast path (no event dict built when nobody listens).

reason mirrors pi's SessionStartEvent.reason ("startup" | "reload" | "new" | "resume" | "fork"); the frontend that knows why the session began passes the right one.

Also claims the loop binding (:meth:_bind_loop), UNCONDITIONALLY — before the zero-extension fast path below, because the binding is about this session, not about who is listening. This is the earliest moment a frontend reliably runs the session on its loop, and a session_start handler is exactly where a driver opens the subscription/timer/socket whose callbacks will later need :meth:submit_threadsafe to have somewhere to marshal to.

Parameters

  • reason: str = 'startup' — (no description)

enable_extension

enable_extension(path: str) -> ExtensionActionResult

tau_agent_core.agent_session.AgentSession.enable_extension

Re-bind a disabled extension by re-invoking its register (S70).

Binds a FRESH runner bucket (its tools/commands/shortcuts re-enter the registry) and re-invokes the stored register(api) — the same entry point the loader called — then fires session_start (reason "enable") so a watcher re-installs. A no-op (unknown / already enabled) returns ok=False.

Parameters

  • path: str — (no description)

extension_runner

tau_agent_core.agent_session.AgentSession.extension_runner: ExtensionRunner

The mutating-hook dispatcher (read-only access).

Mirrors pi's own AgentSession.extensionRunner getter (agent-session-runtime.ts:137 reads this.session.extensionRunner to fire session_before_switch/session_before_fork) — this is the SAME seam, added for the SAME reason: :class:~tau_agent_core.agent_session_runtime.AgentSessionRuntime needs to dispatch its own veto hook (H2, docs/REMOTE-CONTROL.md §4[6]) through the extensions a session already has bound, without a second dispatch mechanism. Not a general extension-authoring seam — extensions register via api.on(...), never through this property.

get_extension_command_args

get_extension_command_args(name: str) -> str | None

tau_agent_core.agent_session.AgentSession.get_extension_command_args

The declared argument placeholder for command name (E7 §3 / S51).

A command may declare "args": "<placeholder>" in its register_command definition to signal that it expects a free-form argument string (parity with typing /name args). The palette (:meth:TauApp.get_system_commands) reads this to decide whether a palette entry, which has no argument line, must first open the S47 input modal to collect the arg string before dispatch.

Returns the placeholder string when declared, None when the command is unknown or declares no args. Fail-Early: a non-string args is a construction bug (the field IS the placeholder text), so it RAISES rather than being coerced or silently ignored.

Parameters

  • name: str — (no description)

get_extension_commands

get_extension_commands() -> list[tuple[str, str]]

tau_agent_core.agent_session.AgentSession.get_extension_commands

List extension-registered slash commands (E5 §5 / S35).

Returns (name, description) for every command an extension registered via api.register_command — the palette (:meth:TauApp.get_system_commands) reads this to LIST them. Description falls back to the empty string when a command omitted one (listing is best-effort chrome, not a durable node).

get_extension_config

get_extension_config(path: str) -> dict[str, Any]

tau_agent_core.agent_session.AgentSession.get_extension_config

One extension's declared config schema and its current values.

The read a settings screen is built from. schema is the extension's CONFIG_SCHEMA, normalized at load into {title, fields} — the same shape ui.form takes, so a head that can render a form can render this with no new widget. values is the live slice api.config returns for the extension, keyed by file stem.

Parameters

  • path: str — A managed path or a unique file stem.

Returns

{path, schema, values}. schema is None for an extension that declares none — the honest answer, and the one that tells a head to offer no settings screen rather than an empty one.

Raises

  • ValueError — path resolves to no managed extension.

get_extension_shortcuts

get_extension_shortcuts() -> list[tuple[str, str, str, str]]

tau_agent_core.agent_session.AgentSession.get_extension_shortcuts

List extension-registered key shortcuts (E10 §6 / S69).

Returns (key, command, args, description) for every shortcut an extension registered via api.register_shortcut — the TUI's ctrl+e chord menu and the command palette read this to LIST + dispatch them. key is the chord tail (bound under the ctrl+e leader); command/args are dispatched through the SAME :meth:run_extension_command path as a typed /name args. description falls back to the target command's registered description, then to the empty string (listing is best-effort chrome, not a durable node).

get_extension_state

get_extension_state() -> 'LoadExtensionsResult'

tau_agent_core.agent_session.AgentSession.get_extension_state

Every managed extension and every file that failed to load, read LIVE.

The read the /extensions listing is built from. It is not the value :meth:load_extensions returned: the extensions come from _loaded_extensions, which :meth:reload_extension REPLACES, so a listing rendered from this reflects a reload and the load-time snapshot did not — the TUI cached that snapshot and showed the pre-reload tool list.

Load errors are the one half that cannot be recomputed, so they are kept from the last :meth:load_extensions call. A file that failed to import is in here and can never be a legal extension_name value, which is why this is a read of its own rather than the extension_name domain: the domain answers "what may I bind", and this answers "what is the state of the extension system".

Returns

class:~tau_agent_core.sdk.LoadExtensionsResult — the same type the loader returns, so the listing formatter and the loader cannot disagree about the shape. Whether each extension is currently enabled is the separate read :meth:list_managed_extensions; a caller that wants both composes them.

get_last_assistant_text

get_last_assistant_text() -> str | None

tau_agent_core.agent_session.AgentSession.get_last_assistant_text

The most recent assistant message's text on the active path, or None.

:func:~tau_agent_core.messages.last_assistant_text applied to :attr:messages, which is where the two skip rules are documented: a turn aborted before it said anything is passed over, and only text blocks contribute.

Returns

The concatenated text, stripped, or None — which covers both "no assistant message yet" and "the last one carried no text".

get_last_compaction

get_last_compaction() -> CompactionRecord | None

tau_agent_core.agent_session.AgentSession.get_last_compaction

The newest compaction entry in the bound log, or None.

Scans session_log.entries() in append order rather than the :class:~tau_agent_core.conversation_tree.ConversationTree active path. Stated as a scope note rather than hidden: on a session with a second open lane this would report a compaction that happened on the other lane, and a lane-aware caller wants ConversationTree.context_entries instead.

Returns

class:CompactionRecord, or None if this session has never compacted — an honest absence, never a fabricated entry.

get_model

get_model() -> dict[str, Any]

tau_agent_core.agent_session.AgentSession.get_model

The active model as {id, provider, context_window} (S45).

A small, stable projection of the loop's Model (pi returns the whole Model; τ exposes only the three fields an extension needs to route, price, or gauge a context window — keeping the extension API decoupled from the full model schema). Read at call time, so it reflects a prior :meth:set_model.

get_session_name

get_session_name() -> str | None

tau_agent_core.agent_session.AgentSession.get_session_name

This session's durable display name, or None if it was never named.

Derived from the log's latest session_info entry at call time, so it is correct across a reload and after another writer renamed the session.

Returns

The name, or None.

Raises

  • RuntimeError — The bound log has no name to read — an in-memory log has nowhere for a session_info entry to live. Distinct from "never named", which is None.

get_session_stats

get_session_stats() -> SessionStats

tau_agent_core.agent_session.AgentSession.get_session_stats

Token accounting for this session, and the compaction settings in force.

The one call behind the get_session_stats capability. Every field was already readable one at a time; what this adds is that two heads asking the question get the same answer, computed once.

Returns

class:SessionStats. Nothing here mutates, and it answers the same on a persisted and an unpersisted session.

get_usage

get_usage() -> dict[str, Any] | None

tau_agent_core.agent_session.AgentSession.get_usage

The most recent completion's token usage, or None (S45).

The public per-completion usage accessor (anchor G14): a copy of the usage dict the provider filled on the last completion's message_end (keys input_tokens / output_tokens / cache_read_tokens / cache_write_tokens / total_tokens / cost / extra). Extensions read this instead of pulling event.message["usage"] out of a notify event or reaching the private _model. None means no completion has landed yet — an honest absence, never a fabricated zero (Fail-Early).

A DEEP copy: cost and extra (the server's timings block, the tool-arg repair count — W8/G4) are nested dicts, so a shallow copy would hand every caller a live alias into the session's own record, and one extension mutating what it read would silently rewrite the completion's measured telemetry for every later reader. Same bug class as the SessionLog.entries() shallow copy.

is_aborted

tau_agent_core.agent_session.AgentSession.is_aborted: bool

Whether the CURRENT turn's abort signal has fired.

docs/REMOTE-CONTROL.md §4[1] T3/G4/G5: RPCHandler._forward_event's backpressure wait (handler.py's _acquire_event_credit) polls this so that a turn told to stop is not ALSO stuck waiting on a host that has stopped reading — see that method's docstring for the full argument. Reads the SAME _abort_signal abort()/is_streaming already read; a fresh one is bound per admitted turn (submit()), so this is always "is the turn in flight right now aborted", never a stale answer from a turn that already finished.

is_addressable

tau_agent_core.agent_session.AgentSession.is_addressable: bool

Whether this session is one the store can hand back later.

:func:~tau_agent_core.session_log.session_log_is_addressable asked of the bound log. Not a constant: a switch_session onto an ephemeral session changes it under a reader's feet, exactly as the active model does.

is_streaming

tau_agent_core.agent_session.AgentSession.is_streaming: bool

Whether the agent loop is currently streaming.

list_managed_extensions

list_managed_extensions() -> list[tuple[str, bool]]

tau_agent_core.agent_session.AgentSession.list_managed_extensions

Every file extension under management as (path, enabled) in load order.

The authoritative runtime state the /extensions listing reads so a disabled extension shows as such: enabled is False exactly when the path is in _disabled_paths (its bucket removed from the runner). Pure read — no path effect, display-only.

load_extensions

load_extensions(explicit_paths: list[str] | None = None, *, discover: bool = True, user_dir: str | None = None, extensions_config: dict[str, dict[str, Any]] | None = None, collect_explicit_errors: bool = False) -> LoadExtensionsResult

tau_agent_core.agent_session.AgentSession.load_extensions

Load file-path extensions into THIS live session (E5 §2, S26/S27).

Discovers + imports each extension and invokes its register(api) against an :class:ExtensionAPI bound to this session's live :class:ExtensionRunner bucket — so the four mutating hooks a file extension registers actually FIRE in this session's loop. This is the seam the E0–E4 loader left disconnected from any live process (E5 §0): _load_extensions was called only by tests, never against a running session's runner.

Binding reuses :meth:_bind_extension_api as the loader's per-extension api_factory: each extension gets its OWN bucket appended in load order and labelled by its file path, sharing this session's registry, event bus, and live :class:ExtensionContext. An async register is awaited by the loader. This runs once per run, AFTER construction (the runner already exists), which is exactly what resolves the load-vs-bind ordering (E5 D-E5-7): build the session, then bind file extensions to its runner here — rather than needing the runner before the session exists.

Error policy is the loader's (Fail-Early): an explicit -e failure RAISES (the user named it); a discovered failure is collected into the returned :class:LoadExtensionsResult errors and skipped. collect_explicit_errors=True demotes an explicit failure to a collected error too (the TUI passes this — it can't abort mid-load; headless leaves it False to keep the raise). The caller surfaces errors (headless → stderr, TUI → a notice); the loader no longer prints them itself, so this is safe to call under a live Textual screen.

extensions_config (S40) is the per-extension config map ({"<file-stem>": {…}}) this run resolved from ~/.tau/config.json + --ext-config overrides. It is stored on the session BEFORE binding so :meth:_bind_extension_api can slice each extension's api.config by file stem. None leaves the constructor-supplied map (default {}).

Parameters

  • explicit_paths: list[str] | None = None — (no description)
  • discover: bool = True — (no description)
  • user_dir: str | None = None — (no description)
  • extensions_config: dict[str, dict[str, Any]] | None = None — (no description)
  • collect_explicit_errors: bool = False — (no description)

messages

tau_agent_core.agent_session.AgentSession.messages: list[dict[str, Any]]

Current conversation messages (active path).

Built at read time from the log's raw entries + persisted cursor by ConversationTree.context_for — the leaf→root walk plus the compaction/branch_summary splice (§2.1, §2.6).

performed

performed(mutation: str, data: dict[str, Any], *, flow: str | None = None) -> Performed

tau_agent_core.agent_session.AgentSession.performed

Stamp a completed mutation with the cursor its capability declares.

The one place E5 — "a mutation's completion carries a cursor, a read never does" — is applied in process, and it is applied MECHANICALLY: whether the cursor rides in data is read off :attr:~tau_agent_core.capabilities.Capability.returns, not decided per call site. Three copies of that decision is how the Tier B review's findings 5 and 6 started, on the wire side, where the same rule is now one helper.

Parameters

  • mutation: str — The capability that ran, a key of :data:~tau_agent_core.capabilities.CAPABILITIES.
  • data: dict[str, Any] — What it returned, keyed as its returns declares, without the cursor — this adds that.
  • flow: str | None = None — The flow that named the mutation, when one did.

Returns

class:~tau_agent_core.flows.Performed carrying data plus the resulting cursor.

Raises

  • KeyError — No capability has that name.
  • ValueError — The named capability is a read, or the caller already put a cursor in data. Both are Fail-Early: a read reporting a cursor is E5 rule 2 broken, and a hand-supplied cursor is a second answer to the question this method exists to answer.

prompt

prompt(text: str, images: list[dict] | None = None, context: list[dict] | None = None) -> list[dict[str, Any]]

tau_agent_core.agent_session.AgentSession.prompt

Send a prompt and run the agent loop — the interactive compatibility wrapper.

docs/SUBMISSION-LIFECYCLE.md "The one door": every prompt() call is now a :class:~tau_agent_core.submission.Submission with source="interactive", submitter="human", multitask_strategy="enqueue" (decision 1 — pi's TUI binds Enter→steer and Alt+Enter→followUp; "steer" now exists (phase 4), and because the strategy is a per-submission parameter the second keybinding remains a one-line change at the TUI call site), expand_commands=True, allow_user_input=True. See :meth:submit for what it does with that, in order, and for why context is threaded as a direct keyword rather than a Submission field.

expand_commands is True again (B2-b), and this method therefore RAISES on a command rather than returning one. Its return type is list[dict] — the turn's messages — which has no channel for a :class:~tau_agent_core.flows.Dispatched, and a resolved command produces no messages. Returning [] would be indistinguishable from a turn that said nothing, so /compact through this method would look like a model that ignored you. The check runs BEFORE :meth:submit, using the same :meth:resolve_command submit() uses, so nothing has run by the time it raises: an extension command is not executed and then reported as an error. A caller that wants commands calls :meth:submit and reads result.command; a caller that wants a turn passes text that is not a command, exactly as today.

A /… that is not a registered command still falls through to the model as ordinary text — unchanged, and the reason this is not a compatibility break for anything that pastes a path.

allow_user_input=True is the one place in the codebase that asserts it: a human typed this, so an extension hook running under this turn may ask that same human a question (see :meth:submit for the enforcement).

This is the LIVE path for the TUI backend, headless print mode, and the SDK (backends.py, headless.py, rpc.py, every examples/* script) — its signature and return type are unchanged from before submit() existed.

Parameters

  • text: str — The prompt text to send.
  • images: list[dict] | None = None — Optional list of image dicts for multimodal prompts.
  • context: list[dict] | None = None — Optional list of message dicts to use as conversation context instead of session messages. This allows passing a pre-built message history (e.g. from a loaded chat) to the agent loop.

Returns

List of messages produced by the agent loop (this turn's new messages only — see :meth:_run_one_turn).

Raises

  • UnsupportedCommandError — text resolves to a command. See above — this method has nowhere to put the outcome, so it refuses before anything runs instead of swallowing it.

record_side_usage

record_side_usage(usage: dict[str, int]) -> None

tau_agent_core.agent_session.AgentSession.record_side_usage

Add an out-of-loop completion's tokens to the session's side ledger.

Called by every path that spends tokens without going through the agent loop. Cumulative and monotonic for the session's lifetime; consumers take a before/after DELTA to attribute the spend to a particular exchange.

Parameters

  • usage: dict[str, int] — (no description)

reload_extension

reload_extension(path: str) -> ExtensionActionResult

tau_agent_core.agent_session.AgentSession.reload_extension

Tear down, re-import from disk, and re-register an extension (S70).

Fires session_shutdown (reason "reload") for the current instance (if enabled), removes its bucket + registry entries, then RE-IMPORTS the file fresh (a new module object — code edits on disk take effect) and re-invokes register against a fresh bucket, finally firing session_start (reason "reload"). A broken file RAISES out of here (Fail-Early — the extension is left torn down; the frontend surfaces the error), which is why reload does not return an ok=False for an import failure.

Parameters

  • path: str — (no description)

resolve_command

resolve_command(text: str) -> CommandInvocation | None

tau_agent_core.agent_session.AgentSession.resolve_command

Would text dispatch as a command, and which one? (submit() step 3.)

Reference: docs/SUBMISSION-LIFECYCLE.md phase 3; :mod:tau_agent_core.commands.

PURE and side-effect-free — it decides, it does not run. That matters because this has two callers and they need the same answer for different reasons:

  • :meth:submit, which is the AUTHORITY. It calls this only when sub.expand_commands is True, which is where the security property lives — this method itself does not know or care who is asking, so it must never be the thing that decides whether dispatch is ALLOWED.
  • a frontend PEEKING before it renders. The TUI must know whether a turn is coming before it appends a user bubble to the transcript and its working message list; asking after the fact would mean rendering a user turn for a /tree that never becomes one. The old code answered this by owning the whole dispatch block; now it asks the same function submit() asks, so the two cannot drift.

Resolution reads the live extension-command registry, so a command an extension registered (or a reload removed) is reflected immediately. See :func:~tau_agent_core.commands.resolve_command for the ordering rule (built-ins win) and for why an unknown /… falls through to the model.

Parameters

  • text: str — (no description)

resolve_extension_target

resolve_extension_target(token: str) -> str | None

tau_agent_core.agent_session.AgentSession.resolve_extension_target

Resolve a user token (full path or file stem) to a managed path.

The /extensions <verb> <token> frontend passes what the user typed; the listing shows stems (Path(path).stem), so disable 21_reminders must map to the loaded path. Matches an exact path first, then a unique stem. Returns None when nothing matches or a stem is ambiguous (the caller reports it — no guessing, Fail-Early).

Parameters

  • token: str — (no description)

route_session_event

route_session_event(event: dict[str, Any]) -> None

tau_agent_core.agent_session.AgentSession.route_session_event

Route a coding-agent session-lifecycle event onto the extension bus.

The seam-3 emitter (session_store.subscribe_session_events, coding-agent) publishes raw dicts {"type": <name>, "session": <Session>, **extra} for session_start / session_before_fork / session_before_compact / session_shutdown. This is the bridge that gives them their first consumer: each dict is re-emitted onto this session's EventBus on a separate string channel named by event["type"] (emit_channel), so api.on("session_before_compact", handler) — a handler subscribed to the same bus the loop emits on — fires. The seam is a distinct channel, NOT a member of the AgentEvent Literal (which carries no session events; §E3c.4, §7 decision E3-c).

Wired from the coding-agent layer (which owns both the emitter and this session) — tau-agent-core never imports session_store. Register it via subscribe_session_events(agent_session.route_session_event).

The seam emitter is synchronous but fires from within the agent loop's running event loop (e.g. append_compaction inside compact()); the bus dispatch is async, so the emit is scheduled as a fire-and-forget task on the running loop (the EventBus contract is fire-and-forget). No running loop is a misuse of the seam, surfaced loudly by get_running_loop (Fail-Early — no swallow, no synchronous fallback).

Parameters

  • event: dict[str, Any] — (no description)

run_extension_command

run_extension_command(name: str, args: str = '') -> ExtensionCommandResult

tau_agent_core.agent_session.AgentSession.run_extension_command

Run an extension-registered slash command (E5 §5 / S35; output channel S46).

Port of pi's _tryExecuteExtensionCommand (agent-session.ts:1143). Looks up name in the session registry and, if found, invokes its handler with (args, ctx) where ctx is the session's ONE live :class:ExtensionContext (the same object hook handlers and api.ui reach through, so a command's ctx.ui.notify paints in the same TUI).

Returns an :class:ExtensionCommandResult: handled is True iff the command existed and ran (False for an unknown command so the caller can fall through and treat the text as a prompt), and output carries the value the handler RETURNED (E7 §3 / S46 — previously discarded, G7). The frontends render output as display-only chrome; it is never appended to the active path (the tree-as-truth invariant is untouched).

Fail-Early: a command registered without a callable handler cannot run, so an attempt to invoke one RAISES rather than silently no-op'ing — a registered-but-inert command is a construction bug, not a runnable command.

Parameters

  • name: str — (no description)
  • args: str = '' — (no description)

set_auto_compaction

set_auto_compaction(enabled: bool) -> bool

tau_agent_core.agent_session.AgentSession.set_auto_compaction

Turn automatic compaction on or off, and report the effective state.

Idempotent. Mutates an in-memory field and appends no log entry, so it works on an unpersisted session where the appending mutations refuse — and so the setting does not survive the process.

Parameters

  • enabled: bool — The state to put it in.

Returns

The state after the call, read back off the settings rather than echoed from the argument.

set_extension_config

set_extension_config(path: str, values: dict[str, Any]) -> ExtensionActionResult

tau_agent_core.agent_session.AgentSession.set_extension_config

Replace an extension's config slice and reload it so the values take.

The write half of :meth:get_extension_config. values is checked against the declared schema first: an undeclared key, or a value whose Python type does not match its field's kind, RAISES rather than being dropped — a settings screen that silently discards a key is the failure this schema exists to prevent.

The reload is what makes the change visible: api.config is captured when the extension's API is bound, so a slice written without one would be read by nobody until the next run.

Persistence is head-local and deliberately not done here. The core does not own ~/.tau/config.json — resolve_extensions_config in tau_coding_agent.headless reads it and hands the merged map in — so these values last for this session, and a head that wants them to survive writes the file itself.

Parameters

  • path: str — A managed path or a unique file stem.
  • values: dict[str, Any] — The complete new slice. Not merged: what is passed is what the extension will read.

Returns

The reload's :class:ExtensionActionResult, with action set to "configure".

Raises

  • ValueError — path resolves to nothing, the extension declares no schema, or values does not satisfy the schema.

set_extension_record_sink

set_extension_record_sink(sink: Any) -> None

tau_agent_core.agent_session.AgentSession.set_extension_record_sink

Route extension activity to a headless JSON record sink (E7 §3 / S49 — G10).

Sets the sink on the session's ONE shared :class:ExtensionUI (via the context), so every loaded extension's api.ui.notify(...) emits a {"type": "extension", …} record through sink instead of the headless stderr line — the parallel record family the --mode json frontend writes alongside the closed AgentEvent set. Only the headless JSON path installs one; the TUI sets a live delegate instead and --mode text leaves it unset (stderr, unchanged). Passing None clears it.

Parameters

  • sink: Any — (no description)

set_headless_ui_defaults

set_headless_ui_defaults(policy: dict[str, str]) -> None

tau_agent_core.agent_session.AgentSession.set_headless_ui_defaults

Set the headless dialog-answer policy for this session (E7 §3 / S48).

Threads the resolved --ui-defaults / config "ui_defaults" map onto the session's ONE shared :class:ExtensionUI (via the context), so a headless dialog opened by any loaded extension auto-answers only for the methods the user explicitly opted into — every other headless dialog raises :class:HeadlessDialogError (Fail-Early, D-E6-2). This is run-scoped runtime config: it is NOT persisted onto the session tree (the policy is re-sourced each run, like --ext-config). The TUI path never calls this — it sets a live delegate instead, so a human answers.

Parameters

  • policy: dict[str, str] — (no description)

Raises

  • ValueError — an unknown method or answer token (propagated from :meth:ExtensionUI.set_headless_defaults); the frontend renders it as a clean CLI error.

set_model

set_model(name: str) -> dict[str, Any]

tau_agent_core.agent_session.AgentSession.set_model

Switch the active model by NAME, effective on the NEXT turn (S45).

Mirrors pi's setModel (agent-session.ts:1444), adapted to τ: pi takes a resolved Model object; τ takes a config model NAME and resolves it through the bound resolver (:meth:set_model_resolver). The new Model is stored on self._model; because every turn rebuilds its AgentLoop with model=self._model (see :meth:_run_one_turn), the switch takes effect on the next completion — never mid-stream.

Scope boundary (documented, not a silent fallback): this switches the Model (id / provider / base_url / context_window) only. The session's API key (self._api_key) is unchanged, so a switch between models that share a provider/key — the preset and router cases this unblocks — is correct; a cross-provider switch to a model needing a different key will surface a loud provider auth error, not silently wrong output. It is a RUNTIME switch: it is not written back to the session header, so a reload resumes on the session's originally stored model.

Whatever the resolver raises for an unknown name (e.g. KeyError or ValueError) propagates unchanged — never swallowed.

Parameters

  • name: str — (no description)

Returns

The new :meth:get_model projection.

Raises

  • RuntimeError — no resolver is bound (Fail-Early — nothing to resolve name against).

set_model_resolver

set_model_resolver(resolver: Callable[[str], Model]) -> None

tau_agent_core.agent_session.AgentSession.set_model_resolver

Bind the model-name resolver used by :meth:set_model (S45).

A frontend calls this after building the session so ctx.set_model(name) can turn a config model NAME into a concrete Model (see backends.make_model_resolver, a closure over config["models"]). The harness core deliberately does not read ~/.tau/config.json itself (layering) — the resolver is the seam.

Parameters

  • resolver: Callable[[str], Model] — (no description)

set_session_name

set_session_name(name: str) -> None

tau_agent_core.agent_session.AgentSession.set_session_name

Give this session a durable display name.

Appends a session_info entry, which is ambient metadata: :class:~tau_agent_core.conversation_tree.ConversationTree never folds one into context, so a rename is persisted and is never model input.

Parameters

  • name: str — The name to give it. Empty is refused rather than stored.

Raises

  • ValueError — name is empty.
  • RuntimeError — The bound log has no append_session_info — session naming needs a log with somewhere durable to put it.

set_ui_delegate

set_ui_delegate(delegate: Any) -> None

tau_agent_core.agent_session.AgentSession.set_ui_delegate

Route extension api.ui calls to a live front-end delegate (E5 §4 / S33).

Sets the delegate on the session's ONE shared :class:ExtensionContext — the same context every bound extension api receives (_bind_extension_api passes self._extension_api.context), so a single call flips the shared :class:ExtensionUI into TUI mode for EVERY loaded extension at once. From then on api.ui.notify(msg, level) reaches the delegate (the TUI screen) instead of the headless stderr sink. Nothing calls this on the headless path, so tau -p keeps the stderr behaviour.

Parameters

  • delegate: Any — (no description)

shutdown_requested

tau_agent_core.agent_session.AgentSession.shutdown_requested: bool

Whether an extension has called ctx.shutdown() on this session (P3, docs/REMOTE-CONTROL.md §4[7]).

Delegates to the one ExtensionContext every bound extension shares (_extension_api.context — the same object ctx.abort()'s signal rebind touches). RPCHandler/transport._read_stdin checks this AFTER dispatching each line (not a poll loop) and, once true, shuts down exactly like stdin EOF.

side_usage

tau_agent_core.agent_session.AgentSession.side_usage: dict[str, int]

Cumulative tokens spent on completions OUTSIDE the agent loop.

A copy — the ledger is the session's own record, and handing out a live alias would let one reader's arithmetic rewrite it (see :meth:get_usage).

state

tau_agent_core.agent_session.AgentSession.state: SessionState

Read-only access to session state. Identity is the session UUID (§4.2).

submit

submit(sub: Submission, *, context: list[dict[str, Any]] | None = None, on_admitted: Callable[[], None] | None = None) -> SubmissionResult

tau_agent_core.agent_session.AgentSession.submit

The single admission point every input source funnels through.

Reference: docs/SUBMISSION-LIFECYCLE.md, "The one door" (phase 1, part 2). TUI, headless, RPC, the SDK, and every extension are meant to converge on this method (:meth:prompt below is now a thin compatibility wrapper over it); the input hook chain moves HERE from the old prompt() body so every submitter gets the same parsing/transform pipeline, not just a human typing.

context is deliberately NOT a field on :class:~tau_agent_core.submission.Submission — it is the pre-existing prompt(text, images, context) capability (the TUI passes its own live working message list, backends.py stream_chat) threaded through as a direct keyword so :meth:prompt can keep its exact signature and return type. No other submission source has ever had a "context override" concept; Submission.text/images remain the only per-submission content fields the cross-source contract carries.

on_admitted (docs/REMOTE-CONTROL.md §4[3], C3) is the RPC surface's admission signal, added for tau_agent_core.rpc.commands's submit/ prompt handlers and additive for every other caller (default None, never invoked, no observable change to any existing call site). C3 needs a response at the moment a submission is ADMITTED — long before the turn this call may go on to run has finished, which is the only point at which this method historically returned anything at all. Fired exactly once, with no arguments, from the one place below where every strategy that is actually going to run a turn ON THIS CALL has already committed to that (see the call site's own comment for why that is a clean, single point rather than one per branch): the branches that return their own SubmissionResult before reaching it — "reject"'s failure, "steer"'s turn-in-flight delivery, "rollback"'s stale-target refusal, "fork"'s admission failure or spawn — are each already a complete, fast result the caller can react to synchronously, so none of them need a separate signal. Fired AFTER :meth:_apply_input_pipeline (phase-2 review B2), not before: an input hook that consumes the submission, or expand_commands resolving a slash command, is a THIRD way to reach a complete result without a turn ever running, and calling on_admitted ahead of that check made its own promise below false for exactly those two cases. The turn's own progress after admission is reported the ordinary way, through the AgentEvent stream this call stamps with the submission's provenance — on_admitted says only "this call is now committed to running a turn", nothing about how that turn goes.

No-raise contract. on_admitted MUST NOT raise. It is called from inside this method's own try (S2), so a raising callback still unwinds through the finally that releases :attr:_turn_lock and resets the per-turn state — the session is not wedged — but the exception itself then propagates straight out of :meth:submit, past everything the turn would otherwise have done (no agent_start, no persisted user node). A caller that cannot guarantee its callback won't raise should catch inside the callback itself and log, not rely on :meth:submit to recover for it.

Owns, in order (the spec's numbered list):

  1. Admission / concurrency — sub.multitask_strategy against the in-flight-turn lock.

  2. "reject" returns accepted=False with a reason, never blocking and never raising.

  3. "enqueue" waits for the in-flight turn (if any) to finish, then runs — never "parks" the way send_user_message(deliver_as= "nextTurn") does; it is guaranteed to run within THIS call.
  4. "rollback" (decision 2): if a turn is genuinely in flight, signals its :attr:_abort_signal (a REQUEST — the turn still unwinds through its own finally, which is what actually persists whatever it produced before the signal was checked), then waits for the slot exactly like "enqueue". Once acquired, it navigates the log back to the leaf THAT turn recorded at ITS OWN admission (:attr:_pre_turn_leaf, read BEFORE this call's own admission overwrites it) via append_navigate — the same "move the cursor, the abandoned suffix falls off the parentId walk" mechanism append_branch_summary uses (minus the summary; see :meth:~tau_agent_core.session_log.SessionLog.append_navigate and ctx.fork(mode="in_place"), which is the identical shape). If NO turn is in flight there is nothing to discard, so this degrades to a plain admission at the current cursor — no navigate, no signal. Known limitation: asyncio.Lock is FIFO; a rollback queued behind an already-waiting "enqueue" submission does not jump the queue (it is granted the slot only after that submission ALSO completes a full turn). Fixing that needs a priority-aware admission primitive this work item does not build. Review fix, must_fix #1: this used to then navigate to the ORIGINAL aborted turn's pre-turn leaf regardless, silently discarding the queued submission's completed work from the active path. It now detects that case (:attr:_current_turn_token bumped by every admitted turn) and refuses (accepted=False) rather than corrupting the tree — the queue-jump gap stands, but it now fails safely instead of silently.
  5. "fork" (decision 2): does not touch :attr:_turn_lock at all — the in-flight turn, if any, is genuinely untouched. The fork point is the log's current committed tip (:attr:~tau_agent_core.session_log.SessionLog.cursor); admission checks it is TURN-COMPLETE (:meth:~tau_agent_core.conversation_tree.ConversationTree.fork_admission_reason) and returns accepted=False with a clear reason rather than producing a bad prefix. On success, a second agent is spawned in a SUPERVISED background task (:meth:_spawn_fork, :attr:_forked_tasks) — reusing ctx.spawn_branch's entire mechanism (BranchView, tool scoping, failure containment, branch_event forwarding) — and submit() returns accepted=True immediately, before the branch's turn finishes; there is no caller left to await it the way spawn_branch's caller does.
  6. "steer" (phase 4): deliver the content into the turn that is ALREADY running — after its current tool calls have appended their results, before its next LLM call. That point has one owner, the agent loop, so this method's whole job is to put the content where the loop will find it: :attr:_pending_steer_messages, which every :class:~tau_agent_core.agent_loop.AgentLoop this session builds receives as steer_queue= and drains immediately before each provider call. Returns accepted=True with NO messages — the transcript belongs to the submission that owns the turn.

    With NO turn in flight there is nothing to steer, and the "next LLM call" is the one this submission is about to make, so it takes the slot and runs an ordinary turn (pi's split exactly: streamingBehavior is consulted only if (this.isStreaming)).

    A steer left undelivered because the turn ended first (a terminate-ing tool, max_turns) STAYS queued and is delivered at the start of the next turn — it is never dropped and never re-ordered. It IS dropped by :meth:abort and by rollback's abort, which is pi's behaviour (clearSteeringQueue()) and the only coherent one: the turn it was aimed at no longer exists.

    This method is not the only door onto that queue: a hook running INSIDE the turn cannot call submit() at all (the reentrancy guard below), so mid-turn extension steering goes through ctx.send_user_message(deliver_as="steer") → :meth:_queue_message, which appends to the same list.

Also records :attr:_pre_turn_leaf — the log's cursor immediately BEFORE this submission's user node — needed for provenance regardless and, per decision 2, what makes rollback cheap: a LATER submission's abort target is exactly this cursor. 2. The input hook chain — moved here from prompt(). The event now carries source/submitter so a handler can branch on provenance (pi's InputSource equivalent). handled still consumes without a turn. 3. Command dispatch, gated on sub.expand_commands (B2-b). The built-ins /compact / /tree / /fork / /extensions and every extension-registered /name args used to be intercepted inside a Textual event handler (tau_coding_agent/app.py on_input_submitted), so no other input source had a command vocabulary at all. The DECISION now lives in :mod:tau_agent_core.commands and is taken here, via :meth:resolve_command, against the POST-input-hook text (the spec's own step order).

A resolved command is not a turn: nothing is persisted, no model call is made, messages is empty, and the decision is reported on :attr:~tau_agent_core.submission.SubmissionResult.command. An extension command is RUN here (:meth:run_extension_command) because any frontend can render the string it returns, and reported as a :class:~tau_agent_core.flows.Performed; a built-in is STEPPED and reported as the arm it is at — a :class:~tau_agent_core.flows.FlowStep, a :class:~tau_agent_core.flows.Ready or a :class:~tau_agent_core.flows.View — because the core cannot push a Textual screen, and a frontend that cannot perform the arm it got must raise :class:~tau_agent_core.commands.UnsupportedCommandError rather than return having done nothing. An unrecognised /… resolves to None and is sent to the model as ordinary text, unchanged.

expand_commands defaults to False and that is a SECURITY property: a bus/timer/webhook payload beginning with "/compact" is literal prompt text, and cannot smuggle a command through. Only a submitter that positively declares itself an interactive frontend gets dispatch. An extension that wants to compact calls the typed API. 4. Session materialisation — a no-op for THIS class: AgentSession requires a session_log at construction, so a session always exists by the time submit() runs. "Create one if absent" is a FRONTEND concern (e.g. the TUI's action_new_chat), not this method's. 5. Persistence honouring store_history — True (the default, and what :meth:prompt always passes) is byte-for-byte the pre-existing persisted pipeline. False runs the same turn through the model but persists nothing (:meth:_run_one_turn's persist= parameter) and skips the end-of-prompt drain / user_turn_end — both are about consolidating DURABLE session state (auto-compaction acts on the persisted log; a followUp re-entry persists its own turn), which a non-persisted submission has no business triggering.

silent is NOT honoured here and raises (see below). It folds into store_history=False at construction (Submission.__post_init__), but that is only half of what it promises: the other half — suppressing renderer-visible output — has no coherent implementation at THIS seam. The core has one multiplexed event bus and its subscribers are not all renderers (latency.py measures off it, rpc.py captures off it, spawn_branch forwards off it), so declining to emit would blind measurement and transport rather than a screen; and marking events instead would only work once renderers honour the mark, which is the multi-stream renderer contract of Block 3 (backends.py stream_chat is single-stream by construction and filters on nothing today). The spec's own Jupyter rule says the same from the other direction: a frontend filters to decide HOW to render and "dropping other sources' events is how a multi-client session becomes incoherent" — deciding that in the core is not this method's business. A caller who wants the half that exists asks for it by name: store_history=False. 6. Run, then return — the admission step above already resolved "run or enqueue" (enqueue is a wait, not a separate code path); this step is the bare return of the produced messages. Every AgentEvent the turn's :class:AgentLoop emits is stamped with this submission's submission_id/source/submitter/correlation (:meth:_stamp_event, "Provenance on events") — Jupyter's parent_header: a renderer decides HOW to show a turn from this, never whether to drop it.

The submission's own SPAN is published too, on the submission_start / submission_end bus channels (B3-a; see :meth:subscribe_channel). An AgentEvent brackets a TURN, and a followUp re-entry runs a second loop.run() inside one submit(), so agent_start/agent_end cannot tell a renderer where one user→answer exchange begins and ends. These two can, and they are what lets a frontend render a turn it never initiated — a bus, timer or extension submission — instead of only the one it happens to be awaiting.

allow_user_input (Jupyter's allow_stdin) is ENFORCED here, for the whole lifetime of the admitted turn: this method publishes it on :data:~tau_agent_core.submission.SUBMISSION_ALLOWS_USER_INPUT, and :class:~tau_agent_core.extension_types.ExtensionUI's blocking dialogs (confirm/select/input/form) consult it before reaching a TUI delegate. False (the Submission default, and what every non-human source gets) means those dialogs take the headless-answer route even in a TUI process — the configured --ui-defaults answer, or :class:~tau_agent_core.extension_types.HeadlessDialogError. That is the enforcement the spec names ("Enforcement stays HeadlessDialogError"), and it is why the capability is per-submission rather than per-process: an embedded τ serving a human at a terminal and a cron-triggered submission must be able to bar the second from opening a modal on the first. Publication is causal, not temporal — see the ContextVar's own docstring.

Depth (decision 3) is DERIVED here, not merely read off the record: the submission's own depth is a floor, and :func:~tau_agent_core.submission.next_submission_depth raises it to one below the turn this call was originated from, if any (:data:~tau_agent_core.submission.DRIVING_SUBMISSION_DEPTH, which this method publishes for the lifetime of the turn it admits). "Originated from" is causal, not temporal — a task spawned by a hook inside a turn inherits that turn's depth even if it reaches this method after the turn ended, and a task that predates the turn inherits nothing however much traffic it delivers mid-turn. That is what makes the cap below reachable: a self-continuing extension (turn_end hook → spawn a task → api.submit → its own turn_end hook → …) climbs one per link and raises on the eleventh, instead of looping forever.

This method is safe only from the session's own event loop (phase 4, "Task marshalling"). A NATS callback, a timer or a webhook handler that fires in a context this session does not own must call :meth:submit_threadsafe instead; calling this one from there RAISES, because the alternative — working by accident, or silently rerouting — is the "bus disconnected randomly" bug the spec names. See :meth:_bind_or_check_loop for exactly what counts as foreign.

Parameters

  • sub: Submission — (no description)
  • context: list[dict[str, Any]] | None = None — (no description)
  • on_admitted: Callable[[], None] | None = None — (no description)

Raises

  • RuntimeError — this call is on a different (live) event loop than the one this session is bound to, or on no event loop at all. The message names :meth:submit_threadsafe as the fix.
  • RuntimeError — the derived depth exceeds MAX_SUBMISSION_DEPTH (decision 3 — a hard cap that raises, never a silent drop or an advisory flag).
  • RuntimeError — this call is reentrant — running on the SAME asyncio.Task as the turn currently holding _turn_lock (review fix, must_fix #2). A hook (input, tool_call, turn_end, user_turn_end) that calls back into :meth:submit/:meth:prompt/ctx.prompt before its own turn returns can never be admitted — every strategy below either waits on or inspects a lock this task already holds, so the call would hang forever with no exception and no log line. A DIFFERENT task submitting concurrently is not this case and is unaffected (see :attr:_turn_task).
  • NotImplementedError — sub.multitask_strategy is not one of the five names in :data:~tau_agent_core.submission.MultitaskStrategy — refused rather than silently falling through to whichever branch is last.
  • UnsupportedCommandError — sub.expand_commands resolved a command that was registered when :meth:resolve_command looked but gone by the time :meth:run_extension_command ran (an extension unloaded across the await). Refusing loudly beats falling through to the model with text the user meant as a command.
  • NotImplementedError — sub.silent is True — renderer suppression lands in Block 3 (see step 5). store_history=False is the part that exists today and is not affected.

submit_threadsafe

submit_threadsafe(sub: Submission, *, context: list[dict[str, Any]] | None = None) -> concurrent.futures.Future[SubmissionResult]

tau_agent_core.agent_session.AgentSession.submit_threadsafe

Submit from a foreign loop or thread — the marshalling door.

docs/SUBMISSION-LIFECYCLE.md "Task marshalling". :meth:submit refuses a foreign caller; this is what that refusal names. Synchronous by design: the caller may have no event loop at all (a paho-mqtt client thread, a watchdog observer, a WSGI request thread), so there is nothing for it to await.

The submission is enqueued onto the session's own loop — loop.call_soon_threadsafe, whose callback queue IS the queue the session drains, in FIFO order, on its own loop — and run there through the ordinary :meth:submit, so every admission semantic is the one the caller asked for.

Each marshalled submission gets its own task, deliberately. A single drainer that awaited each submit() to completion before taking the next would quietly convert multitask_strategy="reject" into "enqueue": two bus events arriving while a turn is in flight would both eventually run instead of the second being refused. nats_bus picked "reject" for a stated reason — "silently queueing would make the agent answer a stale utterance minutes later" — and a marshalling layer that overrides the strategy on the way in is precisely the per-source divergence this lifecycle exists to delete. Ordering is still FIFO: the callbacks run in submission order, each creates its task before the next runs, and submit()'s admission takes the lock without suspending, so the first to arrive is the first admitted.

Parameters

  • sub: Submission — The submission, built by the caller exactly as for :meth:submit.
  • context: list[dict[str, Any]] | None = None — Same meaning as :meth:submit's — a caller-supplied working message list. A foreign thread rarely has one; it is threaded here so this method is a complete counterpart rather than a subset.

Returns

class:concurrent.futures.Future resolving to the class:~tau_agent_core.submission.SubmissionResult — the standard cross-thread handle (asyncio.run_coroutine_threadsafe's return type). A caller that wants the answer blocks on it; a fire-and-forget caller drops it, and any exception is still surfaced through the session's extension-error sink rather than vanishing (see meth:_on_threadsafe_task_done). Cancelling it before the loop picks it up prevents the turn from running at all.

Raises

  • RuntimeError — this session is not bound to a live loop, so there is nothing to marshal onto — Fail-Early rather than queueing into a void that may never be drained. Bind it by running the session on its loop first (emit_session_start, or any submit).
  • RuntimeError — called from the session's OWN loop, where it is a mistake with a silent cost: the returned future can only be resolved by that same loop, so blocking on it deadlocks. Await :meth:submit (or asyncio.create_task it) instead. This is Textual's call_from_thread rule — "must run in a different thread from the app" — not a stylistic preference.

subscribe

subscribe(handler: Callable[[AgentEvent], Any]) -> Callable[[], None]

tau_agent_core.agent_session.AgentSession.subscribe

Subscribe to agent events. Returns unsubscribe function.

Parameters

  • handler: Callable[[AgentEvent], Any] — Callable that receives AgentEvent instances.

Returns

Unsubscribe function that removes the handler.

Example

unsub = session.subscribe(lambda event: print(event.type)) unsub() # Remove the subscription

subscribe_channel

subscribe_channel(channel: str, handler: Callable[..., Any]) -> Callable[[], None]

tau_agent_core.agent_session.AgentSession.subscribe_channel

Subscribe to one of the bus's NON-AgentEvent channels. Returns unsubscribe.

:meth:subscribe covers the AgentEvent stream, whose type Literal is deliberately closed (S49). Everything that is lifecycle rather than a loop event rides a separate string channel instead, and until now the only way to reach one from outside was to reach into _events — so branch_event had no public consumer at all, which is why a fork was unobservable (docs/SUBMISSION-LIFECYCLE.md, end of "Phasing").

The channels a renderer cares about:

  • "submission_start" — (submission, text, images), once per admitted submission that will actually run a turn, before the loop starts. The submission-level bracket agent_start is not: a followUp re-entry runs a second loop.run() inside one submit().
  • "submission_end" — (submission, side_usage), in a finally, so it arrives however the turn ended.
  • "branch_event" — (lane, label, event), one per AgentEvent a spawn_branch/fork sub-agent emits, tagged with the branch's lane.
  • "branch_end" — (lane, label, error), once per branch, in a finally, so it arrives however the branch ended (returned, contained failure, or cancelled by abort()). The branch's OWN agent_end is not that bracket: AgentLoop.run emits it after the while loop rather than from a finally, so a branch that raised or was cancelled never emits one — and a consumer bracketing on it holds the span open forever.

Handlers may be sync or async and are dispatched exactly like :meth:subscribe's (fire-and-forget; an exception is surfaced through the bus's on_error sink, never swallowed).

Parameters

  • channel: str — (no description)
  • handler: Callable[..., Any] — (no description)

summarize_and_navigate

summarize_and_navigate(target_id: str, *, custom_instructions: str | None = None) -> list[dict[str, Any]]

tau_agent_core.agent_session.AgentSession.summarize_and_navigate

Summarize the subtree at target_id, splice the summary on, and move there.

The one tree mutation with a method here. The other four — :func:~tau_agent_core.tree_ops.navigate, elide_span, commit_branch, paste_subtree — take nothing a caller holding a :class:~tau_agent_core.session_log.SessionLog does not already have, so they stay module functions and every head calls them directly. This one needs the summarizer model and its key, which are the session's and are not on its public surface, and it spends tokens that something has to bank.

Parameters

  • target_id: str — The branch point. The subtree BELOW it is summarized, and the branch_summary entry is parented at it.
  • custom_instructions: str | None = None — Extra guidance for the summarizer's system prompt.

Returns

ConversationTree.context_for(cursor) — the flat message list a head swaps into its transcript.

Raises

  • ValueError — The summarizer returned nothing usable. Raised by session_manager.summarize_branch, never fabricated into an empty summary here.

turn_lock

tau_agent_core.agent_session.AgentSession.turn_lock: asyncio.Lock

The admission lock a turn holds for its duration (read-only access).

The SAME object as :attr:_turn_lock (see the class docstring for its full reentrancy contract) — exposed, read-only, for :class:~tau_agent_core.agent_session_runtime.AgentSessionRuntime's H4 atomicity guarantee: acquiring it and performing a session_log swap synchronously (no await between acquire and release) is what proves (a) no NEW turn can start mid-swap, because every strategy in :meth:submit either checks .locked() or awaits this same lock, and (b) every AgentEvent an in-flight turn was going to emit has already been dispatched to every subscriber before the swap proceeds — :meth:~tau_agent_core.events.EventBus.emit awaits each handler to completion, and this lock is released only after :meth:~tau_agent_core.agent_loop.AgentLoop.run's own await has returned and this session's turn-teardown finally block runs.

NOT a general-purpose extension seam. The same reentrancy hazard :meth:submit guards against (see _turn_task in the class docstring) applies here — never acquire this from a hook running on the session's own current turn task, or it deadlocks exactly as a reentrant submit() call would.

Argument

class Argument(name: str, domain: str, description: str, cardinality: Cardinality = 'one', required: bool = True, scope: MessageIdScope | None = None)

tau_agent_core.capabilities.Argument

One argument a flow needs before it can run.

A head renders this and sends back a bound value; it never decides what may be entered. The pair (domain, cardinality) replaces the five form field kinds: a single-select and a multi-select are the same domain at two cardinalities, and a checkbox is the boolean domain.

Constructor parameters

  • name: str — The argument's name, as the bound-argument mapping keys it.
  • domain: str — The name of its :class:Domain, a key of :data:DOMAINS.
  • description: str — The prompt a head shows for it.
  • cardinality: Cardinality = 'one' — "one" for a single value, "many" for a list.
  • required: bool = True — Whether the flow can run without it. An optional argument is offered as a step and may be skipped.
  • scope: MessageIdScope | None = None — For the message_id domain, which entries are candidates — one of ConversationTree.complete_message_id's scopes. None everywhere else.

BranchPlan

class BranchPlan(attach: str, keeps: tuple[str, ...], copies: tuple[str, ...], elide_from: str | None, hidden: int)

tau_agent_core.tree_surgery.BranchPlan · since 0.9.7

A branch worked out from a set of marked nodes, before anything is written.

Constructor parameters

  • attach: str — The existing entry the branch grows from — the last kept item. The commit moves the cursor here before minting anything.
  • keeps: tuple[str, ...] — Marked entries used IN PLACE, root-most first. Always at least one: the root-most mark is an existing entry and is trivially a chain of one. These keep their identity, their ids, and their recorded usage.
  • copies: tuple[str, ...] — Marked entries to be minted as new entries under attach, in order. Empty when the whole selection was already a real ancestor chain — §6.3's case A, the shape that mints nothing.
  • elide_from: str | None — The entry an elide should resume at once the branch is minted, or None when no elide is wanted or when one would hide nothing. Set only for drop_context=True.
  • hidden: int — How many entries that elide would remove from the fold. 0 whenever elide_from is None.

mints

tau_agent_core.tree_surgery.BranchPlan.mints: int · since 0.9.7

How many new entries the commit will append for the copies.

BranchSummary

tau_agent_core.session.BranchSummary

Summary of a session branch for display in the TUI session tree.

Used by Phase 5 compaction display and TUI to show session branching structure without loading full session contents.

branch_id

tau_agent_core.session.BranchSummary.branch_id: str

Unique identifier for this branch

created_at

tau_agent_core.session.BranchSummary.created_at: int

Branch creation timestamp (ms since epoch)

is_compacted

tau_agent_core.session.BranchSummary.is_compacted: bool

Whether the branch has been compacted

message_count

tau_agent_core.session.BranchSummary.message_count: int

Number of messages in this branch

parent_id

tau_agent_core.session.BranchSummary.parent_id: str | None

ID of the parent session/branch (None for root)

session_path

tau_agent_core.session.BranchSummary.session_path: str

Path to the session JSONL file

status

tau_agent_core.session.BranchSummary.status: Literal['idle', 'running', 'aborting', 'error']

Current branch status

updated_at

tau_agent_core.session.BranchSummary.updated_at: int

Last update timestamp (ms since epoch)

BranchView

class BranchView(log: SessionLog, parent_id: str | None, *, lane: str, label: str)

tau_agent_core.session_log.BranchView

A second cursor over ONE underlying log — the branch sub-agent's handle (C2/W14).

A :class:SessionLog in its own right (so an AgentSession accepts it with no changes), but not a second log: same id, same entries() (the whole shared list, not a filtered one), same durable storage. What it owns is its own leaf. Every append it makes goes to the underlying log via append_at — parented at the branch's leaf — and moves only this view's cursor, never the spawning one.

Its writes are not marked on disk. :attr:lane is an in-memory identity used to route this branch's live output (the TUI opens a render lane per branch); it is not stamped on the entries, because a durable "this came from a sub-agent" tag makes a three-way fork and three sub-agents — structurally identical trees — behave oppositely for every reader that asks "does this entry belong to the conversation I am looking at?" (docs/LANE-REMOVAL.md §1, §3.2). The answer to that question is ancestry from the reader's own cursor, and it is available without any tag.

Two properties then fall out of the existing fold for free, which is the entire reason C2 is tractable (JMFTS-INTEGRATION-PLAN.md §9.2):

  • The sub-agent's context is already correct. AgentSession.messages is ConversationTree(log.entries(), log.cursor).context_for(). Hand it this view and the leaf→root walk from the branch leaf yields exactly the shared conversation prefix (down to parent_id) plus the branch's own work. Choosing parent_id IS choosing the sub-agent's inherited context. No new context plumbing exists.
  • Isolation is mutual, and it is structural rather than enforced. context_for walks leaf→root, so a branch's entries are never ancestors of the primary leaf and cannot leak into the primary context — whatever their kind, and even though entries() returns them. Nothing filters them out; the tree shape means they are never on the path.

entries() deliberately returns the WHOLE list (branch + primary). It must: the ConversationTree fold resolves parentId by dict lookup, so hiding the primary prefix from a branch would break the very walk that gives it its context.

Constructor parameters

  • log: SessionLog — (no description)
  • parent_id: str | None — (no description)
  • lane: str — (no description)
  • label: str — (no description)

append_at

append_at(parent_id: str | None, entry_type: str, payload: dict[str, Any]) -> str

tau_agent_core.session_log.BranchView.append_at

Pass through to the underlying log — a branch adds nothing to the entry.

Parameters

  • parent_id: str | None — (no description)
  • entry_type: str — (no description)
  • payload: dict[str, Any] — (no description)

append_branch_summary

append_branch_summary(summary: str, from_id: str | None) -> str

tau_agent_core.session_log.BranchView.append_branch_summary

Re-parent to the branch point before appending (pi branchWithSummary).

Parameters

  • summary: str — (no description)
  • from_id: str | None — (no description)

append_compaction

append_compaction(summary: str, first_kept_id: str, tokens_before: int, *, summarizer_model_id: str, summary_usage: dict[str, int], covered_entries: int, covered_tokens: int, agent_spec_id: str | None) -> str

tau_agent_core.session_log.BranchView.append_compaction

Fail-Early on an unknown anchor, exactly as the concrete stores do — a compaction whose firstKeptId names nothing silently drops the whole kept region from the fold rather than raising.

A branch adds nothing to the provenance and hides nothing from it: the five §8 fields are written verbatim, exactly as append_at writes a branch's entries with no marker of their own (docs/LANE-REMOVAL.md §1).

Parameters

  • summary: str — (no description)
  • first_kept_id: str — (no description)
  • tokens_before: int — (no description)
  • summarizer_model_id: str — (no description)
  • summary_usage: dict[str, int] — (no description)
  • covered_entries: int — (no description)
  • covered_tokens: int — (no description)
  • agent_spec_id: str | None — (no description)

append_custom_entry

append_custom_entry(custom_type: str, data: dict[str, Any]) -> str

tau_agent_core.session_log.BranchView.append_custom_entry

No description. This object is marked but undocumented.

Parameters

  • custom_type: str — (no description)
  • data: dict[str, Any] — (no description)

append_custom_message

append_custom_message(message: dict[str, Any], custom_type: str) -> str

tau_agent_core.session_log.BranchView.append_custom_message

No description. This object is marked but undocumented.

Parameters

  • message: dict[str, Any] — (no description)
  • custom_type: str — (no description)

append_elide

append_elide(first_kept_id: str, *, covered_entries: int, covered_tokens: int, agent_spec_id: str | None) -> str

tau_agent_core.session_log.BranchView.append_elide

Fail-Early on an unknown anchor, exactly as append_compaction does — see :class:InMemorySessionLog for why a dangling anchor is the worst of the unknown-id cases rather than merely a rejected call.

Parameters

  • first_kept_id: str — (no description)
  • covered_entries: int — (no description)
  • covered_tokens: int — (no description)
  • agent_spec_id: str | None — (no description)

append_message

append_message(message: dict[str, Any]) -> str

tau_agent_core.session_log.BranchView.append_message

No description. This object is marked but undocumented.

Parameters

  • message: dict[str, Any] — (no description)

append_navigate

append_navigate(target_id: str | None) -> str

tau_agent_core.session_log.BranchView.append_navigate

Move THIS branch's leaf. The primary cursor is untouched.

Parameters

  • target_id: str | None — (no description)

cursor

tau_agent_core.session_log.BranchView.cursor: str | None

This branch's leaf — independent of the underlying log's primary cursor.

entries

entries() -> list[dict[str, Any]]

tau_agent_core.session_log.BranchView.entries

No description. This object is marked but undocumented.

id

tau_agent_core.session_log.BranchView.id: str

The UNDERLYING session's id — a branch is a lane in one conversation, not a second conversation. (Its own identity is :attr:lane.)

Capability

class Capability(name: str, kind: CapabilityKind, description: str, on_wire: bool = False, arguments: tuple[Argument, ...] | None = (), returns: dict[str, Any] | None = None)

tau_agent_core.capabilities.Capability

One read, or one mutation. The unit every head can address by name.

Constructor parameters

  • name: str — The capability's name. Where a capability is already an RPC verb, this is that verb's name, because hosts depend on it.
  • kind: CapabilityKind — "read" returns data and changes nothing; "mutation" changes state. The distinction is the one the RPC layer's E5 rule already enforces — a mutation's completion carries a cursor, a read never does.
  • description: str — What it does, in one line.
  • on_wire: bool = False — Whether rpc.COMMAND_TABLE exposes it today. False is a statement about the wire, not about the capability: it is callable in-process either way.
  • arguments: tuple[Argument, ...] | None = () — What it takes, said once for every caller — the wire schema, the flow that ends in it and the head that performs it all read this tuple. () means it takes nothing. None means its parameters cannot be written in this vocabulary and the hand-written wire schema is their only statement; submit is the case, carrying images and a correlation object that no :class:Domain describes. None is not a default: a capability says which of the three it is.
  • returns: dict[str, Any] | None = None — What it gives BACK, in the same JSON Schema vocabulary rpc.commands._assert_supported_schema accepts — type, properties, required. Every capability declares one and _check_registry refuses a None, so "what comes back" is answerable without reading a head's source. Read by :func:tau_agent_core.rpc.schema.result_schema_for, which is what the wire publishes.

CloneResult

tau_agent_core.session.CloneResult

Result of cloning a session into a new independent session.

Unlike fork (which creates a branch of the same session), clone creates a completely independent copy.

cloned_at

tau_agent_core.session.CloneResult.cloned_at: int

Timestamp of the clone operation (ms since epoch)

cloned_session_id

tau_agent_core.session.CloneResult.cloned_session_id: str

ID of the newly cloned session

cloned_session_path

tau_agent_core.session.CloneResult.cloned_session_path: str

Path to the cloned session JSONL file

entry_count

tau_agent_core.session.CloneResult.entry_count: int

Number of entries cloned

original_session_id

tau_agent_core.session.CloneResult.original_session_id: str

ID of the original session that was cloned

CompactionEntry

tau_agent_core.session.CompactionEntry

Compaction entry: records session compaction.

Reference: SUBPHASE-0.0.md, "6. Session Entry JSON Schema" section.

compacted_entries

tau_agent_core.session.CompactionEntry.compacted_entries: list[str]

No description. This object is marked but undocumented.

first_kept_id

tau_agent_core.session.CompactionEntry.first_kept_id: str

No description. This object is marked but undocumented.

id

tau_agent_core.session.CompactionEntry.id: str

No description. This object is marked but undocumented.

parent_id

tau_agent_core.session.CompactionEntry.parent_id: str | None

No description. This object is marked but undocumented.

summary

tau_agent_core.session.CompactionEntry.summary: str

No description. This object is marked but undocumented.

timestamp

tau_agent_core.session.CompactionEntry.timestamp: int

No description. This object is marked but undocumented.

tokens_saved

tau_agent_core.session.CompactionEntry.tokens_saved: int

No description. This object is marked but undocumented.

type

tau_agent_core.session.CompactionEntry.type: Literal['compaction']

No description. This object is marked but undocumented.

CompactionRecord

class CompactionRecord(id: str, timestamp: str, summary: str, first_kept_id: str | None, tokens_before: int | None)

tau_agent_core.agent_session.CompactionRecord

One compaction entry in the session log, read back as a record.

What :meth:AgentSession.get_last_compaction returns. Field-for-field the entry's own payload with the log's camelCase keys spelled the way the rest of this package spells them, so a reader never has to know that firstKeptId is how it is written on disk.

Constructor parameters

  • id: str — The entry's id.
  • timestamp: str — When it was appended, ISO-8601.
  • summary: str — The generated summary text the compaction spliced in.
  • first_kept_id: str | None — The entry the context resumes at — everything before it on the path is folded away.
  • tokens_before: int | None — The context size the compaction was measured against.

ConversationSession

tau_agent_core.session_catalog.ConversationSession

The frontend surface the concrete file Session already has, as a Protocol.

A derived Protocol (SessionLog plus more), not a widening of SessionLog itself: AgentSession never calls header/messages/ context/model/backend/display_title/append_model_change/ append_session_info (it only touches the members on SessionLog), so keeping them off SessionLog avoids forcing InMemorySessionLog — the SDK's default, no-frontend log — to grow members it would never use. They live here instead because the TUI (app.py) and headless (headless.py) DO call them, through whatever :class:SessionCatalog handed them the session.

That same rule is why this Protocol is SMALLER than the concrete file Session. Session also has cwd, name, shutdown() and append_thinking_change() — all four have zero callers anywhere in src (shutdown() in particular is shadowed by the unrelated AgentSession.emit_session_shutdown, which is what the frontends actually call). Putting them here would force every future store — the JMFTS one next — to implement four members nobody invokes, which is precisely the cost SessionLog's docstring exists to avoid. Add a member here when a caller appears, not before.

@runtime_checkable only verifies member NAMES are present (via isinstance/hasattr), never signatures — an isinstance(x, ConversationSession) pass is not a contract pass. It does not check that display_title takes no arguments, that model raises rather than returning None, or any other behavioural promise; that is what a contract test suite (in the spirit of the W5 SessionLog suite) is for, not this Protocol.

append_model_change

append_model_change(model: str, backend: str) -> str

tau_agent_core.session_catalog.ConversationSession.append_model_change

No description. This object is marked but undocumented.

Parameters

  • model: str — (no description)
  • backend: str — (no description)

append_session_info

append_session_info(name: str) -> str

tau_agent_core.session_catalog.ConversationSession.append_session_info

No description. This object is marked but undocumented.

Parameters

  • name: str — (no description)

backend

tau_agent_core.session_catalog.ConversationSession.backend: str

The latest model_change backend. Raises if the session has none.

context

tau_agent_core.session_catalog.ConversationSession.context: list[dict[str, Any]]

The active-path context at the current cursor — the model-input source.

display_title

display_title() -> str

tau_agent_core.session_catalog.ConversationSession.display_title

A short human label: the name, else the first user message, else model.

tau_agent_core.session_catalog.ConversationSession.header: dict[str, Any]

The line-1 header (raw, mutable copy per call).

messages

tau_agent_core.session_catalog.ConversationSession.messages: list[dict[str, Any]]

Raw linear fold: every message entry in load order (ignores cursor).

model

tau_agent_core.session_catalog.ConversationSession.model: str

The latest model_change model. Raises if the session has none.

ConversationTree

class ConversationTree(entries: list[dict[str, Any]], cursor: str | None)

tau_agent_core.conversation_tree.ConversationTree

Pure, I/O-free view over an append-only session entry log + a cursor.

entries are session_store-shaped dicts (camelCase parentId); the log is never mutated — navigate only moves the in-memory cursor.

Constructor parameters

  • entries: list[dict[str, Any]] — (no description)
  • cursor: str | None — (no description)

children_of

children_of(entry_id: str | None) -> list[str]

tau_agent_core.conversation_tree.ConversationTree.children_of

The ids parented at entry_id, oldest first.

The downward edge, where :meth:path walks upward. tree_surgery needs it to copy a subtree — the shape below a node is part of what a copy re-creates — and reading it from the index built at construction is what keeps that walk from re-scanning every entry per node.

Parameters

  • entry_id: str | None — The parent id, or None for the root-level entries.

Returns

The child ids sorted by timestamp, the same order :meth:tree puts them in. An unknown id has no children, which is the same answer as a leaf — this is a graph reader, not a validator (see :meth:contains).

complete_message_id

complete_message_id(scope: MessageIdScope = 'in_session', cursor: str | None = None, query: str = '', limit: int = _COMPLETION_LIMIT) -> MessageIdCompletion

tau_agent_core.conversation_tree.ConversationTree.complete_message_id

Candidate entry ids for a half-typed message_id argument, with previews.

The enumerator for the message_id domain. A host cannot compute this for itself — it holds no tree — and until it existed, every capability taking an entry id was uncallable by anything that had not first been handed an id by something else, which is the hole get_models closed for set_model and list_sessions for switch_session.

Pairs, not bare ids. A raw a3f9c1 is not a thing a person can choose between, so every match carries the entry's first line; the caller shows the preview and sends back the id.

Two matching rules, because an id and a preview are searched differently. A match is a case-sensitive PREFIX of the entry id, or a case-insensitive SUBSTRING of its preview. The first is completion (the reader is part-way through an id); the second is search (the reader remembers what the message said, not what it was called). An empty query matches everything in scope, which is how the scope becomes browsable.

Bounded, and it says when it truncated: matches stops at limit while total reports what the scope really held, so a caller is told it is seeing a prefix of the answer rather than silently shown one (the G3 rule attachments.complete_attachment already follows for paths).

Parameters

  • scope: MessageIdScope = 'in_session' — Which entries are candidates. "in_session" is every entry; "ancestors_of_cursor" is the parent chain from the root to cursor inclusive; "descendants_of_cursor" is the subtree below it, excluding cursor itself.
  • cursor: str | None = None — The entry the two scoped variants are relative to. None uses this tree's own cursor. Passed rather than always read, so a caller enumerating for a sub-agent can scope to THAT agent's cursor.
  • query: str = '' — The typed text. "" matches everything in scope.
  • limit: int = _COMPLETION_LIMIT — How many matches to return at most.

Returns

class:MessageIdCompletion: the matches in tree order (root-most first), and the true count before the limit was applied.

Raises

  • KeyError — cursor — or this tree's cursor, when cursor is None — names no entry, and the scope is one that needs it. Fail-Early: a scope relative to a node that does not exist would otherwise return an empty list, which reads as "nothing matched".

contains

contains(entry_id: str) -> bool

tau_agent_core.conversation_tree.ConversationTree.contains

Whether entry_id names an entry in this tree.

The question :meth:entry answers by raising. It exists for callers that are validating a caller-supplied id and want to say what is wrong with it (tree_surgery.selection_order names every unknown id at once, which a try/except KeyError per id cannot do).

Parameters

  • entry_id: str — The id to look for.

Returns

True when the id names an entry.

context_entries

context_entries(leaf: str | None = None) -> list[dict[str, Any]]

tau_agent_core.conversation_tree.ConversationTree.context_entries

Root→leaf entry list with the compaction/branch_summary splice applied.

The entry-level counterpart of :meth:context_for (which converts these to loop messages). This is exactly what SessionManager._build_active_path returned, so it feeds compaction.prepare_compaction unchanged — the AgentSession compaction path builds it over the live entries instead of the retired System-A manager (§2.6). leaf=None uses the stored cursor.

Parameters

  • leaf: str | None = None — (no description)

context_for

context_for(leaf: str | None = None) -> list[dict[str, Any]]

tau_agent_core.conversation_tree.ConversationTree.context_for

Root→leaf message list with compaction/branch_summary splices applied.

The entry-level fold is _build_active_path (anchor on the LAST summary in the path; drop kept-region entries whose linear order precedes the boundary); the entry→message conversion is get_active_messages. leaf=None uses the stored cursor.

Parameters

  • leaf: str | None = None — (no description)

descendants_of

descendants_of(entry_id: str | None) -> list[str]

tau_agent_core.conversation_tree.ConversationTree.descendants_of

Every id in the subtree below entry_id, parents before children.

:meth:children_of one level at a time, to the leaves. Breadth-first, so the order is stable and a reader scanning the result meets a node before anything hanging off it — the same order tree_surgery.plan_paste needs its mints in.

Parameters

  • entry_id: str | None — The subtree root, or None for the whole tree.

Returns

The descendant ids, EXCLUDING entry_id itself. An unknown id has no descendants, matching :meth:children_of.

entry

entry(entry_id: str) -> dict[str, Any]

tau_agent_core.conversation_tree.ConversationTree.entry

The raw log entry entry_id names.

Exists for the tree browser's detail pane, which renders a node's FULL body while :class:TreeNode carries only the one-line preview the browser row needs. Widening TreeNode instead would push the whole message onto every consumer of :meth:tree — the RPC surface included — to serve one pane.

Parameters

  • entry_id: str — (no description)

Raises

  • KeyError — no entry has that id. The browser builds its rows from these same entries, so a miss is a broken index, not a missing body.

fork_admission_reason

fork_admission_reason(target_id: str | None) -> str | None

tau_agent_core.conversation_tree.ConversationTree.fork_admission_reason

Whether target_id is safe to fork FROM, or the reason it is not.

docs/SUBMISSION-LIFECYCLE.md's concrete admission check for multitask_strategy="fork": a fork point must be a TURN-COMPLETE entry. Forking at an assistant message whose toolCall blocks have no matching toolResult on this path yields a prefix most providers reject outright — a chat-completions turn cannot end on an assistant message that declares tool calls with no results attached, and BranchView's ancestors-only walk (I1, NODE-ADDRESSABLE-AGENTS.md §2) means a toolResult appended AFTER target_id (a descendant) can never rescue it — there is no "wait for the rest of the turn to land" here, only "this point was, or was not, already complete when it was appended".

target_id=None (fork before the root) is trivially safe: there is no context yet, so nothing can be pending.

Walks the RAW root→target ancestor chain (:meth:path, not :meth:context_for's compaction/elide-spliced view — compaction never cuts mid-tool-call, see compaction.find_valid_cut_points's "toolResult: not a cut point", so the two views agree on this question), tracking the most recently seen assistant message's outstanding tool_call ids and clearing each as its toolResult is walked past. Whatever is still outstanding at target_id is the gap.

Returns a human-readable rejection reason, or None if target_id is a safe fork point.

Parameters

  • target_id: str | None — (no description)

Raises

  • ValueError — target_id names no entry — Fail-Early, mirroring :func:~tau_agent_core.session_log.open_branch's own check on the same value (a dangling fork point would hand the second agent an empty or wrong context with no error).

message_text

message_text(entry_id: str) -> str

tau_agent_core.conversation_tree.ConversationTree.message_text

entry_id's message flattened to plain text, or "" if it has none.

The whole body, where :attr:TreeNode.preview is its first line elided to a row. Added for the tree browser's revise gesture (PLAN-0.9.4 §4, item 2), which puts a user message back in the input to be edited — a preview would hand back a truncated version of what the reader typed.

"" for an entry with no message at all (a navigate, an agent_spec) — those are records, not text, and the caller asking for one is asking about the wrong node rather than hitting an error.

Parameters

  • entry_id: str — (no description)

Raises

  • KeyError — no entry has that id, same as :meth:entry.
navigate(entry_id: str | None) -> None

tau_agent_core.conversation_tree.ConversationTree.navigate

Move the cursor to entry_id (pi branch). Raises if unknown.

Parameters

  • entry_id: str | None — (no description)

path

path(leaf: str | None = None) -> list[dict[str, Any]]

tau_agent_core.conversation_tree.ConversationTree.path

The raw leaf→root entry chain, reversed to root→leaf order.

No splicing — every entry on the parentId chain (all kinds). A cycle guard mirrors _build_active_path (session_manager.py:571-579). leaf=None uses the stored cursor.

Parameters

  • leaf: str | None = None — (no description)

subtree_text

subtree_text(from_id: str) -> str

tau_agent_core.conversation_tree.ConversationTree.subtree_text

Concatenated text of the whole SUBTREE at from_id (BFS, every descendant).

Verbatim port of _extract_branch_messages (session_manager.py:627-702) with parent_id → parentId and a branch_summary case added alongside compaction (§2.4). Feeds the "summarize branch" prompt.

The bound is structural: descendants of the node the caller named — nothing else. It reaches down, never sideways: a sibling subtree, a concurrent branch rooted elsewhere, and the primary line above from_id are all outside it, because none of them is reachable by following parentId edges downward from from_id.

This is deliberately NOT the lane filter it replaces (docs/LANE-REMOVAL.md §6.2). That filter asked who wrote this entry and refused to descend from a primary entry into a sub-agent branch hanging under it; this asks what did the caller name, and a sub-agent's subtree under from_id IS part of what happened there, so it is summarized with it. The difference is visible exactly when the two disagree — and when they do, write provenance is the wrong answer: an extension that deliberately summarizes a region containing a sub-agent's work has said which region it means, while the old rule silently returned a different one. A caller that wants only the sub-agent's own work names the branch root; a caller that wants only the primary line asks for context_for, not this.

Parameters

  • from_id: str — (no description)

tree

tree() -> list[TreeNode]

tau_agent_core.conversation_tree.ConversationTree.tree

Parent/child TreeNode roots for the browser (pi getTree).

A well-formed session has one root (first entry with parentId is None); orphaned entries (broken parent chain) are also returned as roots. Each node's children are sorted by timestamp (oldest first); is_leaf marks the current cursor. Roots keep load order.

CustomMessageEntry

tau_agent_core.session.CustomMessageEntry

Custom message entry: extension-generated messages.

Reference: SUBPHASE-0.0.md, "6. Session Entry JSON Schema" section.

custom_type

tau_agent_core.session.CustomMessageEntry.custom_type: str

No description. This object is marked but undocumented.

id

tau_agent_core.session.CustomMessageEntry.id: str

No description. This object is marked but undocumented.

message

tau_agent_core.session.CustomMessageEntry.message: dict[str, Any]

No description. This object is marked but undocumented.

parent_id

tau_agent_core.session.CustomMessageEntry.parent_id: str | None

No description. This object is marked but undocumented.

timestamp

tau_agent_core.session.CustomMessageEntry.timestamp: int

No description. This object is marked but undocumented.

type

tau_agent_core.session.CustomMessageEntry.type: Literal['customMessage']

No description. This object is marked but undocumented.

Domain

class Domain(name: str, description: str, free: bool = False, values: tuple[str, ...] | None = None, enumerator: str | None = None, field_kind: str = 'text')

tau_agent_core.capabilities.Domain

A named type in τ's object model, and how its values are found.

What a flow argument carries instead of a list of strings. A domain is what lets a value set be COMPUTED — the sessions that exist right now, the entries in this tree — and what lets an enumerator return a label beside each value, so a person chooses between "the turn where the tests failed" rather than between two hex strings.

Exactly one of the three answers applies, and __post_init__ enforces it: free (any value is legal — text, a number, a boolean), values (a fixed set known here), or enumerator (a capability that computes the set).

Those three say how a value is FOUND. :attr:field_kind says how it is ASKED FOR, and the two are not the same question: session_id and model_name both compute their values, but a head can offer every model at once and cannot offer every session. Nothing else in the registry records that difference, which is why it is stated rather than derived. See docs/TUI-STYLE-GUIDE.md §2.

Constructor parameters

  • name: str — The domain's name, as an argument declares it.
  • description: str — What a value of this domain means, for a person reading a form.
  • free: bool = False — Whether any value is legal. A free domain has no enumerator and no fixed values, and a head renders it as a plain field.
  • values: tuple[str, ...] | None = None — The fixed legal values, when there are few and they never change.
  • enumerator: str | None = None — The name of the :class:Capability that computes the legal values, when they depend on live state.
  • field_kind: str = 'text' — Which of :data:~tau_agent_core.extension_types.FORM_FIELD_KINDS a head renders a SINGLE value of this domain as. "select" asserts the whole legal set can be put on screen at once; a domain whose set is unbounded or merely large says "text" and is completed against instead. A head may substitute a richer control than the kind names — the TUI answers session_id with its filtered picker — and may never substitute a poorer one.

DomainValue

class DomainValue(value: str, label: str)

tau_agent_core.flows.DomainValue

One legal value for a domain, and the text that identifies it to a person.

Constructor parameters

  • value: str — What a caller binds.
  • label: str — What a caller shows. Equal to value for a domain whose values are already readable.

DomainValues

class DomainValues(domain: str, values: tuple[DomainValue, ...], total: int)

tau_agent_core.flows.DomainValues

What :func:enumerate_domain returns.

Constructor parameters

  • domain: str — The domain's name.
  • values: tuple[DomainValue, ...] — The legal values, bounded by the caller's limit.
  • total: int — How many there were before the limit, so a caller can tell an empty domain from a truncated listing.

ExtensionActionResult

class ExtensionActionResult(action: str, path: str, ok: bool, message: str)

tau_agent_core.agent_session.ExtensionActionResult

Outcome of a runtime /extensions action (E10 §6 / S70).

Runtime enable/disable/reload (lifting the D-E5-6 read-only stance) return this so the frontend can report what happened as display-only chrome — it is never appended to the active path, so the tree-as-truth invariant is untouched. ok distinguishes a completed action from a legitimate no-op / bad target (e.g. a name that is not loaded); message is the human-readable line the listing box shows. A hard failure (a broken file on reload) still raises out of the action — ok=False is reserved for reportable, non-exceptional outcomes (Fail-Early).

Constructor parameters

  • action: str — (no description)
  • path: str — (no description)
  • ok: bool — (no description)
  • message: str — (no description)

ExtensionCommandResult

class ExtensionCommandResult(handled: bool, output: object | None = None)

tau_agent_core.agent_session.ExtensionCommandResult

Outcome of :meth:AgentSession.run_extension_command (E7 §3 / S46).

run_extension_command used to return a bare bool (handled / unknown) and DISCARD the handler's return value, so an extension command could only toast (G7). This carries both:

  • handled — True iff a command by that name existed and ran (False lets the caller fall through, e.g. treat the text as a prompt). This is the old bool, now a named field.
  • output — the value the handler RETURNED (a string, a renderable, or None). The frontends render it as a display-only system box (TUI) / printed-or-emitted text (headless); it is chrome, never model input — it is NOT appended to the active path, so the E5 §1 tree-as-truth invariant holds (a command that wants a durable node uses ctx explicitly).

Unknown command → ExtensionCommandResult(handled=False) (no output).

Constructor parameters

  • handled: bool — (no description)
  • output: object | None = None — (no description)

output_text

output_text() -> str | None

tau_agent_core.agent_session.ExtensionCommandResult.output_text

Coerce output to display text, or None when there is nothing to show.

A handler that returned None (or an empty string) has no output box. Any other value is rendered as its string form — report commands return markdown strings; a non-str value is stringified so the text/JSON channels stay honest rather than fabricating a shape. Display-only.

Flow

class Flow(name: str, description: str, mutation: str, arguments: tuple[Argument, ...] = ())

tau_agent_core.capabilities.Flow

An ordered argument list ending in one mutation.

One mutation, named outright. Two mutations that a head might reach from the same gesture are two flows, because they differ in what they take or in what they cost: navigate and summarize_and_navigate differ in both, and enable_extension and reload_extension take the same argument but not the same risk — one of them raises on a file that no longer imports. A gesture that offers a choice between flows is a view, and it composes them in head code.

Constructor parameters

  • name: str — The flow's name. It is the slash command, the CLI subcommand and the palette entry, all three.
  • description: str — What it does, shown in completion and in the palette.
  • mutation: str — The mutation capability this flow ends in.
  • arguments: tuple[Argument, ...] = () — The arguments, in the order a head should ask for them. A discriminated flow would need per-branch arguments, which is the second reason there is no such thing here.

FlowStep

class FlowStep(flow: str, argument: Argument, domain: Domain, cursor: str | None, bound: dict[str, Any])

tau_agent_core.flows.FlowStep

One argument a flow still needs, and everything required to ask for it.

A head renders this and calls :func:next_step again with one more argument bound. It never decides what may be entered: domain says where the legal values come from, and :func:enumerate_domain computes them.

Constructor parameters

  • flow: str — The flow's name.
  • argument: Argument — The argument being asked for.
  • domain: Domain — That argument's :class:~tau_agent_core.capabilities.Domain, resolved here so a head need not look it up.
  • cursor: str | None — The entry a scoped message_id argument is relative to, carried through from the :func:next_step call so the head hands it straight back to :func:enumerate_domain.
  • bound: dict[str, Any] — The arguments already bound, so a head redrawing a form has them.

ForkResult

tau_agent_core.session.ForkResult

Result of forking a session into a new branch.

branches

tau_agent_core.session.ForkResult.branches: list[BranchSummary]

List of branch summaries including the new branch

forked_at

tau_agent_core.session.ForkResult.forked_at: int

Timestamp of the fork operation (ms since epoch)

new_session_id

tau_agent_core.session.ForkResult.new_session_id: str

ID of the newly created forked session

new_session_path

tau_agent_core.session.ForkResult.new_session_path: str

Path to the new session JSONL file

original_session_id

tau_agent_core.session.ForkResult.original_session_id: str

ID of the original session that was forked

InMemorySessionLog

class InMemorySessionLog(id: str | None = None)

tau_agent_core.session_log.InMemorySessionLog

A minimal, RAM-only :class:SessionLog for the SDK default path.

The append algebra (parentId chaining off the current leaf, 8-hex ids, latest-wins cursor, navigate moving the tip to its target) is exactly session_store.Session._append/append_navigate — but with no disk flush. Entries are camelCase (parentId/firstKeptId/fromId) so :class:~tau_agent_core.conversation_tree.ConversationTree reads them the same as an on-disk Session. No header, no system message, no file: a fresh log has zero entries (messages == []) until the first append.

Constructor parameters

  • id: str | None = None — (no description)

append_at

append_at(parent_id: str | None, entry_type: str, payload: dict[str, Any]) -> str

tau_agent_core.session_log.InMemorySessionLog.append_at

Explicit-parent append (see the Protocol). Does NOT move this log's leaf.

Parameters

  • parent_id: str | None — (no description)
  • entry_type: str — (no description)
  • payload: dict[str, Any] — (no description)

append_branch_summary

append_branch_summary(summary: str, from_id: str | None) -> str

tau_agent_core.session_log.InMemorySessionLog.append_branch_summary

Move the leaf to from_id (the branch point) then append, mirroring Session.append_branch_summary (session_store.py:433) and pi branchWithSummary (session-manager.ts:1272): the summary parents at the branch point so the abandoned children become a sibling branch that drops out of context_for via the parentId walk. Without this re-parent the summary would append off the current leaf and the abandoned branch would stay on the active path — the exact divergence ctx.summarize_branch (E3-ctx / S19) exposed on the SDK/in-memory path.

Fail-Early: a non-None from_id must name a real entry (parity with Session.append_branch_summary).

Parameters

  • summary: str — (no description)
  • from_id: str | None — (no description)

append_compaction

append_compaction(summary: str, first_kept_id: str, tokens_before: int, *, summarizer_model_id: str, summary_usage: dict[str, int], covered_entries: int, covered_tokens: int, agent_spec_id: str | None) -> str

tau_agent_core.session_log.InMemorySessionLog.append_compaction

Fail-Early on an unknown splice anchor, as append_navigate already does.

An anchor matching no entry is never found by the tree fold, so the entire kept region silently drops out of the context — the worst of the three unknown-id cases, because it corrupts model input rather than raising.

The five provenance keywords land as camelCase payload fields beside the three that were already here (see the Protocol for what each records and why none of them has a default — TREE-BROWSER-AS-EDITOR.md §8, §11.3).

Parameters

  • summary: str — (no description)
  • first_kept_id: str — (no description)
  • tokens_before: int — (no description)
  • summarizer_model_id: str — (no description)
  • summary_usage: dict[str, int] — (no description)
  • covered_entries: int — (no description)
  • covered_tokens: int — (no description)
  • agent_spec_id: str | None — (no description)

append_custom_entry

append_custom_entry(custom_type: str, data: dict[str, Any]) -> str

tau_agent_core.session_log.InMemorySessionLog.append_custom_entry

Persist a durable, NON-message customEntry node (E6 §2 / S39).

The reloadable backing for api.append_entry (formerly the RAM-only registry _entry_store, lost on restart — G4). It carries the extension's {customType, data} as its own tree entry KIND — deliberately NOT a message/customMessage, so :class:~tau_agent_core.conversation_tree.ConversationTree never folds it into the loop context and convert_to_llm never sees it: it is tree-as-backplane state, on the durable path and readable through ctx.entries(), but excluded from model input. Folds onto the active path like any node (it advances the leaf); the exclusion is that context_for emits no message for it (conversation_tree.py). The foundation S56's TreeStore reconstructs from ctx.entries() on reload.

Parameters

  • custom_type: str — (no description)
  • data: dict[str, Any] — (no description)

append_custom_message

append_custom_message(message: dict[str, Any], custom_type: str) -> str

tau_agent_core.session_log.InMemorySessionLog.append_custom_message

Persist an extension-injected custom message as a customMessage node.

The durable form of a before_agent_start injection (E5 §3.1 / S29): its own tree entry KIND, carrying the stored message (role: "custom") plus the top-level customType (the extension-origin identity). ConversationTree folds it onto the active path like a message entry (it is not a splice anchor) and the wire remaps custom→user, so the injected content reaches the model and survives a reload byte-identically.

Parameters

  • message: dict[str, Any] — (no description)
  • custom_type: str — (no description)

append_elide

append_elide(first_kept_id: str, *, covered_entries: int, covered_tokens: int, agent_spec_id: str | None) -> str

tau_agent_core.session_log.InMemorySessionLog.append_elide

Persist a summary-less splice anchor (W3, NODE-ADDRESSABLE-AGENTS.md): the same splice as append_compaction, minus summary/tokensBefore. Fail-Early for the identical reason append_compaction validates — an anchor matching no entry is never found by _active_path_entries's forward scan, so the ENTIRE kept region silently drops out of the fold.

Three provenance keywords, not five: §8.2's missing size plus §8.3's frame id. There is no summary here, so there is no summarizer and no summary cost (see the Protocol).

Parameters

  • first_kept_id: str — (no description)
  • covered_entries: int — (no description)
  • covered_tokens: int — (no description)
  • agent_spec_id: str | None — (no description)

append_message

append_message(message: dict[str, Any]) -> str

tau_agent_core.session_log.InMemorySessionLog.append_message

No description. This object is marked but undocumented.

Parameters

  • message: dict[str, Any] — (no description)

append_navigate

append_navigate(target_id: str | None) -> str

tau_agent_core.session_log.InMemorySessionLog.append_navigate

Persist a cursor move; the leaf advances to target_id (not to the navigate entry itself), mirroring Session.append_navigate. Fail-Early: a non-None target must name a real entry.

Parameters

  • target_id: str | None — (no description)

cursor

tau_agent_core.session_log.InMemorySessionLog.cursor: str | None

No description. This object is marked but undocumented.

entries

entries() -> list[dict[str, Any]]

tau_agent_core.session_log.InMemorySessionLog.entries

No description. This object is marked but undocumented.

MessageEntry

tau_agent_core.session.MessageEntry

Message entry: stores a single message.

Reference: SUBPHASE-0.0.md, "6. Session Entry JSON Schema" section.

id

tau_agent_core.session.MessageEntry.id: str

No description. This object is marked but undocumented.

message

tau_agent_core.session.MessageEntry.message: dict[str, Any]

No description. This object is marked but undocumented.

parent_id

tau_agent_core.session.MessageEntry.parent_id: str | None

No description. This object is marked but undocumented.

timestamp

tau_agent_core.session.MessageEntry.timestamp: int

No description. This object is marked but undocumented.

type

tau_agent_core.session.MessageEntry.type: Literal['message']

No description. This object is marked but undocumented.

MessageIdCompletion

class MessageIdCompletion(matches: tuple[MessageIdMatch, ...], total: int)

tau_agent_core.conversation_tree.MessageIdCompletion

What :meth:ConversationTree.complete_message_id returns.

Constructor parameters

  • matches: tuple[MessageIdMatch, ...] — The candidates, in tree order, bounded by the caller's limit.
  • total: int — How many entries matched BEFORE the limit was applied, so a caller can tell a scope that held nothing from one that held more than it was shown.

MessageIdMatch

class MessageIdMatch(entry_id: str, preview: str)

tau_agent_core.conversation_tree.MessageIdMatch

One candidate entry id, with the text that lets a person recognise it.

Constructor parameters

  • entry_id: str — The entry's id — the value a caller sends back.
  • preview: str — The entry's first line, the same row the tree browser draws.

PasteMint

class PasteMint(source_id: str, parent_source_id: str | None, kind: str, payload: dict[str, Any])

tau_agent_core.tree_surgery.PasteMint · since 0.9.7

One entry a paste will mint, and where it hangs.

Constructor parameters

  • source_id: str — The entry being copied. Written into the new entry as copiedFrom.
  • parent_source_id: str | None — The source id of this mint's parent, or None when it hangs directly from the paste target. Source ids, not new ids, because the new ones do not exist until the commit runs — the commit keeps a source→new map as it walks the mints in order.
  • kind: str — The entry type to append, always one of :data:COPYABLE_KINDS.
  • payload: dict[str, Any] — The append payload, ready for SessionLog.append_at, including copiedFrom.

PastePlan

class PastePlan(target: str, mints: tuple[PasteMint, ...], skipped: tuple[str, ...])

tau_agent_core.tree_surgery.PastePlan · since 0.9.7

A subtree copy worked out against the tree, before anything is written.

Constructor parameters

  • target: str — The entry the copied subtree hangs from.
  • mints: tuple[PasteMint, ...] — The entries to append, parents before children.
  • skipped: tuple[str, ...] — Source entries left out because their kind is not copyable (:data:COPYABLE_KINDS). Their children re-parent onto the nearest copied ancestor, so the copy is shorter than the original rather than broken — and the count is reported to the reader rather than swallowed.

Performed

class Performed(flow: str | None, mutation: str, data: dict[str, Any], cursor: str | None = None)

tau_agent_core.flows.Performed

What a capability produced. The past tense of :class:Ready.

:class:Ready names a mutation and what to call it with; this names the same mutation and what came back. Until it existed a head had no record for that half, which is why the four generic mutations reported four unrelated Python types — a dict, a str, a bool and an ExtensionActionResult — and the TUI stringified whichever it got.

data is a plain dict rather than a per-capability type because it is what crosses the wire and what a --mode json line holds. Its typing lives in :attr:~tau_agent_core.capabilities.Capability.returns; nothing re-validates it on every call, the way nothing re-validates a params dict in process, and test_performed_records.py is what checks each producer against the schema.

Constructor parameters

  • flow: str | None — The flow that named the mutation, when a flow did. None when a caller performed the capability directly.
  • mutation: str — The capability that ran.
  • data: dict[str, Any] — What it returned, keyed as its returns declares. JSON-able.
  • cursor: str | None = None — The session-log cursor after the call, or None for a session with no log. It is the promoted copy of data["cursor"] wherever the capability declares one, so a head reads the same field for every mutation instead of knowing which ones carry it.

summary

summary() -> str

tau_agent_core.flows.Performed.summary

One line a head can show, from the data alone.

Written once here for the reason :func:~tau_agent_core.commands.unsupported_command_message is: three heads want the same sentence, and the alternative is each inventing its own.

A capability whose returns declares message has already written the line — the three extension actions do — and it is used verbatim. Otherwise the fields are named with their values, cursor excluded because it moves on nearly every mutation and says nothing to a reader.

Returns

The line, never empty: a mutation that returned only a cursor still names itself.

Ready

class Ready(flow: str, mutation: str, arguments: dict[str, Any])

tau_agent_core.flows.Ready

A flow with every required argument bound: the mutation, and what to call it with.

A commitment, not a proposal. Once this is returned the arguments are complete, and performing it is the caller's business — the core does not ask a second time. A head that wants an "are you sure" renders one from this, because it names both halves.

Constructor parameters

  • flow: str — The flow's name.
  • mutation: str — The capability to perform — the flow's, always. Which mutation runs is a property of which flow was named, never of what was bound.
  • arguments: dict[str, Any] — What to perform it with, keyed by the mutation's own parameter names, so a caller can splat it.

SessionCatalog

tau_agent_core.session_catalog.SessionCatalog

The injected, storage-agnostic seam for constructing/finding sessions.

One instance per run (TUI or headless), replacing direct calls to the concrete file Session.create/create_in_memory/load/fork and list_sessions. An ABC, not a Protocol, because the two orchestration methods below (most_recent, resolve_ref) have exactly one correct implementation — built purely out of the five abstract methods — that every catalog should share rather than reimplement.

The five abstract methods are the storage-specific primitives a concrete catalog must supply:

  • create / create_ephemeral — new persisted / in-memory session.
  • load(ref) — reconstruct a session from a :class:SessionInfo's ref.
  • fork(source, cwd) — a new session carrying source's history.
  • list(cwd) — newest-first listing metadata, cwd=None for every dir.

create

create(cwd: str, model: str, backend: str, *, system_prompt: str | None = None, name: str | None = None) -> ConversationSession

tau_agent_core.session_catalog.SessionCatalog.create

Create a new persisted session.

Parameters

  • cwd: str — (no description)
  • model: str — (no description)
  • backend: str — (no description)
  • system_prompt: str | None = None — (no description)
  • name: str | None = None — (no description)

create_ephemeral

create_ephemeral(cwd: str, model: str, backend: str, *, system_prompt: str | None = None, name: str | None = None) -> ConversationSession

tau_agent_core.session_catalog.SessionCatalog.create_ephemeral

Create a new in-memory (unpersisted) session — --no-session.

Parameters

  • cwd: str — (no description)
  • model: str — (no description)
  • backend: str — (no description)
  • system_prompt: str | None = None — (no description)
  • name: str | None = None — (no description)

fork

fork(source: ConversationSession, cwd: str) -> ConversationSession

tau_agent_core.session_catalog.SessionCatalog.fork

A new session carrying source's history; source is untouched.

Parameters

  • source: ConversationSession — (no description)
  • cwd: str — (no description)

list

list(cwd: str | None = None) -> list[SessionInfo]

tau_agent_core.session_catalog.SessionCatalog.list

Listing metadata, newest (by modified) first.

cwd given → that directory/scope only; None → every scope.

Parameters

  • cwd: str | None = None — (no description)

load

load(ref: str) -> ConversationSession

tau_agent_core.session_catalog.SessionCatalog.load

Reconstruct a session from a :class:SessionInfo's ref.

Raises when ref names nothing loadable (Fail-Early — never guess).

Parameters

  • ref: str — (no description)

most_recent

most_recent(cwd: str | None = None) -> ConversationSession | None

tau_agent_core.session_catalog.SessionCatalog.most_recent

The most recently modified session in cwd, loaded — or None.

Shared across every catalog: built purely from list + load, so a second implementation gets "continue the last session" for free.

Parameters

  • cwd: str | None = None — (no description)

resolve_ref

resolve_ref(ref: str, *, cwd: str | None = None) -> ConversationSession

tau_agent_core.session_catalog.SessionCatalog.resolve_ref

Resolve a --session/--fork REF to a loaded session, by id.

Generalizes the file-store-era headless._resolve_session_ref. The storage-agnostic half lives here: a REF is a session id — an exact match wins, else a unique id prefix. cwd scopes the search (None searches every scope, mirroring the old all_sessions widening, which no CLI flag ever set, so the dead parameter is folded into cwd). Zero or multiple matches raise LookupError (Fail-Early: never guess which session was meant).

A catalog whose refs have their own directly addressable form — the file store's .jsonl path — OVERRIDES this to try that form first and then delegates back here via super(). That knowledge is deliberately NOT in this base class: .jsonl and FileNotFoundError are filesystem concepts, and core owns zero I/O. A JMFTS catalog's document ids resolve through the id path below unchanged.

Parameters

  • ref: str — (no description)
  • cwd: str | None = None — (no description)

SessionEntry

tau_agent_core.session.SessionEntry

Root session entry.

Reference: SUBPHASE-0.0.md, "6. Session Entry JSON Schema" section.

cwd

tau_agent_core.session.SessionEntry.cwd: str | None

No description. This object is marked but undocumented.

id

tau_agent_core.session.SessionEntry.id: str

No description. This object is marked but undocumented.

model

tau_agent_core.session.SessionEntry.model: str | None

No description. This object is marked but undocumented.

model_name

tau_agent_core.session.SessionEntry.model_name: str | None

No description. This object is marked but undocumented.

parent_id

tau_agent_core.session.SessionEntry.parent_id: str | None

No description. This object is marked but undocumented.

session_name

tau_agent_core.session.SessionEntry.session_name: str | None

No description. This object is marked but undocumented.

system_prompt

tau_agent_core.session.SessionEntry.system_prompt: str | None

No description. This object is marked but undocumented.

timestamp

tau_agent_core.session.SessionEntry.timestamp: int

No description. This object is marked but undocumented.

type

tau_agent_core.session.SessionEntry.type: Literal['session']

No description. This object is marked but undocumented.

tau_agent_core.session.SessionInfo

tau_agent_core.session.SessionInfo

Session metadata and info.

Lightweight info about a session, used for listing and display. Does not include the full session contents.

created_at

tau_agent_core.session.SessionInfo.created_at: int

Creation timestamp (ms since epoch)

cwd

tau_agent_core.session.SessionInfo.cwd: str | None

Working directory for this session

id

tau_agent_core.session.SessionInfo.id: str

Session identifier (optional, auto-generated if not provided)

message_count

tau_agent_core.session.SessionInfo.message_count: int

Total number of messages

model

tau_agent_core.session.SessionInfo.model: str | None

Model identifier used for this session

model_config

tau_agent_core.session.SessionInfo.model_config

No description. This object is marked but undocumented.

model_name

tau_agent_core.session.SessionInfo.model_name: str | None

Human-readable model name

name

tau_agent_core.session.SessionInfo.name: str | None

No description. This object is marked but undocumented.

session_path

tau_agent_core.session.SessionInfo.session_path: str

Path to the JSONL session file

status

tau_agent_core.session.SessionInfo.status: Literal['idle', 'running', 'aborting', 'error']

Current session status

tool_count

tau_agent_core.session.SessionInfo.tool_count: int

No description. This object is marked but undocumented.

turn_count

tau_agent_core.session.SessionInfo.turn_count: int

Total number of turns

updated_at

tau_agent_core.session.SessionInfo.updated_at: int

Last update timestamp (ms since epoch)

tau_agent_core.session_catalog.SessionInfo

class SessionInfo(ref: str, id: str, cwd: str, name: str | None, created: datetime, modified: datetime, message_count: int, first_message: str, last_message: str, parent: str | None, error: str | None = None)

tau_agent_core.session_catalog.SessionInfo

Lightweight listing metadata for one session — the picker's fast-list record.

ref replaces what used to be a concrete Path (tau_coding_agent's former session_store.SessionInfo.path): a storage-agnostic handle that a :class:SessionCatalog's load() accepts back to reconstruct the full session. For the file store it is str(path); a future JMFTS-backed catalog would put a document id here instead. This dataclass does no I/O of its own — building one from an on-disk file is tau_coding_agent.session_store's job (read_session_info), which is file-store-specific and stays there.

Constructor parameters

  • ref: str — (no description)
  • id: str — (no description)
  • cwd: str — (no description)
  • name: str | None — (no description)
  • created: datetime — (no description)
  • modified: datetime — (no description)
  • message_count: int — (no description)
  • first_message: str — (no description)
  • last_message: str — (no description)
  • parent: str | None — (no description)
  • error: str | None = None — (no description)

display_title

display_title() -> str

tau_agent_core.session_catalog.SessionInfo.display_title

No description. This object is marked but undocumented.

tau_agent_core.session_manager.SessionInfo

class SessionInfo(session_path: str, session_name: str | None = None, cwd: str | None = None, model: str | None = None, model_name: str | None = None, created_at: int = 0, message_count: int = 0, status: str = 'idle', entries: list[dict] | None = None)

tau_agent_core.session_manager.SessionInfo

Metadata about a session, for listing and display.

Constructor parameters

  • session_path: str — Path to the JSONL session file
  • session_name: str | None = None — Human-readable session name
  • cwd: str | None = None — Working directory for this session
  • model: str | None = None — Model identifier used for this session
  • model_name: str | None = None — Human-readable model name
  • created_at: int = 0 — Creation timestamp (ms since epoch)
  • message_count: int = 0 — Total number of message entries
  • status: str = 'idle' — Session status string
  • entries: list[dict] | None = None — Entries for this session (in-memory mode only)

SessionLog

tau_agent_core.session_log.SessionLog

The persistence surface AgentSession reads from and appends to.

Exactly the methods AgentSession calls, plus the two cursor-move / branch-summary appenders the tree-browser (Part 2) drives through the same facade. append_model_change / append_thinking_change / append_session_info are deliberately absent — AgentSession never calls them (the TUI/headless call those on the concrete Session directly), so keeping them off the Protocol avoids an unused-method contract (Fail-Early).

Precondition: a conversation has exactly one writing process (NODE-ADDRESSABLE-AGENTS.md Decision 6). Concurrency inside a conversation is lanes — open a :class:BranchView, which is a second cursor over the same entry log, never a second writer of it. Concurrency across processes is Session.fork(mode="export") (tau_coding_agent.session_store): a verbatim entry copy into a new file with its own header, "self-contained — no cross-file chaining," handed to a second process as an independent conversation. There is no third option — a second process must never append to the same conversation's log a first process is also appending to.

This is stated as a precondition, not enforced by a guard, a stat check, or an id change here. The hazard it heads off is not id collision (an earlier draft of the design doc said otherwise): _generate_entry_id retries against the log's own id set, so a same-process collision merely redraws, and a cross-process collision window is only the entries the other writer added since the last load — negligible. The real hazard is that the cursor — the file store's _leaf_id — is process-local memory that nothing re-reads: two writers both parent their next append off the same node, and the conversation silently becomes a fork instead of a line, with resolve_cursor picking one writer's turns on reload and orphaning the other's on disk, unlinked from any tree walk. Guarding against that here would mean giving this Protocol a liveness check no single implementation needs today; the fix that exists (fork(mode="export")) already prices out the correct trade at process scale (a full copy) against a lane's trade at turn scale (zero copy) — see Decision 6 for the full argument.

append_at

append_at(parent_id: str | None, entry_type: str, payload: dict[str, Any]) -> str

tau_agent_core.session_log.SessionLog.append_at

Append an entry at an EXPLICIT parent.

The one primitive C2/W14's branch sub-agents need, and the ONLY member this Protocol grew for them. Every appender above is "append_at at the current leaf, then move the leaf"; this exposes the parent so a second cursor can write to the same log without disturbing the first. It does not move the store's own leaf — a branch's writes must never move the tip of the cursor that spawned it.

Deliberately ONE new member rather than the branch()-per-store shape first sketched in the C2 plan: with append_at in place, the branch handle itself (:class:BranchView) is storage-agnostic and lives here once, instead of being reimplemented — and kept in sync — inside each of the three stores. It is also the member runtime_checkable can actually police, since that checks member names, never signatures: a store that ignored a parent_id= kwarg bolted onto the six existing appenders would pass an isinstance check while silently ignoring the explicit parent.

It writes NO branch marker. A branch's identity is in-memory (:attr:BranchView.lane, which the TUI uses as a render-routing key); nothing durable distinguishes a sub-agent's entry from a user's fork of the same shape, because nothing should — see docs/LANE-REMOVAL.md §1.

Parameters

  • parent_id: str | None — (no description)
  • entry_type: str — (no description)
  • payload: dict[str, Any] — (no description)

append_branch_summary

append_branch_summary(summary: str, from_id: str | None) -> str

tau_agent_core.session_log.SessionLog.append_branch_summary

No description. This object is marked but undocumented.

Parameters

  • summary: str — (no description)
  • from_id: str | None — (no description)

append_compaction

append_compaction(summary: str, first_kept_id: str, tokens_before: int, *, summarizer_model_id: str, summary_usage: dict[str, int], covered_entries: int, covered_tokens: int, agent_spec_id: str | None) -> str

tau_agent_core.session_log.SessionLog.append_compaction

Persist a compaction splice anchor and the provenance of its summary.

The five keyword arguments are TREE-BROWSER-AS-EDITOR.md §8's decision, widened onto this Protocol by §11.3. They are keyword-only and have no defaults, which is the whole point: every one of them already exists at the call site and was being discarded there (§8.1), so a caller that cannot name one fails where the value lives rather than recording None. A default would make "no provenance recorded" indistinguishable from "provenance recorded as unknown" — the swallowed-gap pattern the repo's Fail-Early rule exists to prevent (§11.3, rejected option 1).

  • summarizer_model_id — the id of the model that WROTE summary. Not the conversation's model: AgentSession._summarizer() (agent_session.py:866) routes a local_summarizer policy's compaction through a different one, so the two genuinely differ and the transcript could not previously say which (§8.1).
  • summary_usage — what generating summary cost, i.e. CompactionResult.usage (compaction.py:112-119). Compaction summarises a full context window and fires automatically, so this is routinely the most expensive call in a session; tokens_before says how big the context was, this says what shrinking it charged.
  • covered_entries / covered_tokens — the span this anchor removes from the fold, as measured at write time: the count of entries and :func:~tau_agent_core.compaction.estimate_span_tokens over them. Passed in rather than recomputed here — see :meth:append_elide for why.
  • agent_spec_id — the id of the agent_spec customEntry in force over the covered span, from :func:agent_spec_in_force. None is a real answer (a pi-imported log, or a store driven without an AgentSession, has no such node), not an absent one — the absence of a default is what keeps those two cases apart. §8.3: only the ID is recorded, never the prompt text, and the record it points at may lag what was actually bound, because _record_agent_spec re-runs at construction and set_model but not at load_extensions.

agent_spec_id is deliberately NOT validated against the entry set, unlike first_kept_id. The hazards are not comparable: a dangling first_kept_id is never found by the fold's forward scan and silently drops the entire kept region from model input, while a dangling agent_spec_id only makes one browser row unhelpful. agent_spec is a RECORD and never a contract (NODE-ADDRESSABLE-AGENTS.md Decision 3), so nothing reads it back to reconstruct anything that could then be wrong.

Parameters

  • summary: str — (no description)
  • first_kept_id: str — (no description)
  • tokens_before: int — (no description)
  • summarizer_model_id: str — (no description)
  • summary_usage: dict[str, int] — (no description)
  • covered_entries: int — (no description)
  • covered_tokens: int — (no description)
  • agent_spec_id: str | None — (no description)

append_custom_entry

append_custom_entry(custom_type: str, data: dict[str, Any]) -> str

tau_agent_core.session_log.SessionLog.append_custom_entry

No description. This object is marked but undocumented.

Parameters

  • custom_type: str — (no description)
  • data: dict[str, Any] — (no description)

append_custom_message

append_custom_message(message: dict[str, Any], custom_type: str) -> str

tau_agent_core.session_log.SessionLog.append_custom_message

No description. This object is marked but undocumented.

Parameters

  • message: dict[str, Any] — (no description)
  • custom_type: str — (no description)

append_elide

append_elide(first_kept_id: str, *, covered_entries: int, covered_tokens: int, agent_spec_id: str | None) -> str

tau_agent_core.session_log.SessionLog.append_elide

Persist a summary-less splice anchor (W3, NODE-ADDRESSABLE-AGENTS.md).

Same anchor kind ConversationTree._active_path_entries folds compaction on — "skip the path from here back to first_kept_id" — with the summary/tokens_before fields dropped, since there is nothing to render. Structured exclusion in tree SHAPE (Decision 2): no new per-node flag, no new walker, and a branch whose path never reaches this node is completely unaffected (Decision 7 keeps it out of no fold but context_for — entries() stays total).

It takes three of :meth:append_compaction's five provenance arguments, not five. An elide generates no summary, so there is no summarizer model and no summary cost; summarizer_model_id/summary_usage would be parameters whose only admissible value is a placeholder, which is the same swallowed gap §11.3 rejects, wearing symmetry as a disguise. It does have a covered span and it does run under an agent_spec, so it takes those.

covered_entries/covered_tokens close §8.2 — an elide recorded no size at all, where a compaction at least recorded tokensBefore. They are passed in, not computed here, for three reasons. (1) covered_tokens is not recoverable from structure at read time (§8.2 says exactly this), so it must cross the boundary regardless; computing the count here while the token figure comes from the caller would let the two describe different spans, with nothing to catch it. (2) The count arithmetic already exists, once, in ConversationTree._splice_span_phrase (conversation_tree.py:552); reproducing it in the five stores that implement this Protocol would be five copies, and pushing it down here would make every store depend on ConversationTree. (3) Both call sites already compute the span and throw it away — tree_ops.elide_span builds the exact hidden list for its no-op refusal check — which is §8.1's pattern verbatim.

Parameters

  • first_kept_id: str — (no description)
  • covered_entries: int — (no description)
  • covered_tokens: int — (no description)
  • agent_spec_id: str | None — (no description)

append_message

append_message(message: dict[str, Any]) -> str

tau_agent_core.session_log.SessionLog.append_message

No description. This object is marked but undocumented.

Parameters

  • message: dict[str, Any] — (no description)

append_navigate

append_navigate(target_id: str | None) -> str

tau_agent_core.session_log.SessionLog.append_navigate

No description. This object is marked but undocumented.

Parameters

  • target_id: str | None — (no description)

cursor

tau_agent_core.session_log.SessionLog.cursor: str | None

The current leaf (tip) entry id; None before the first entry.

entries

entries() -> list[dict[str, Any]]

tau_agent_core.session_log.SessionLog.entries

The ordered, append-only raw entries (all kinds), in load order.

id

tau_agent_core.session_log.SessionLog.id: str

Stable session identity (a UUID — never a filesystem path, §4.2).

SessionManager

class SessionManager(cwd: str | None = None, sessions_dir: str | None = None)

tau_agent_core.session_manager.SessionManager

File-level session management.

Handles JSONL session persistence with tree structure, entry types, and session operations.

Constructor parameters

  • cwd: str | None = None — (no description)
  • sessions_dir: str | None = None — (no description)

append_entry

append_entry(entry: dict) -> str

tau_agent_core.session_manager.SessionManager.append_entry

Append a JSONL entry to the current session file.

Returns the entry's id.

Parameters

  • entry: dict — (no description)

apply_compaction

apply_compaction(first_kept_entry_id: str, summary: str, compacted_entry_ids: list[str] | None = None, tokens_saved: int = 0) -> str

tau_agent_core.session_manager.SessionManager.apply_compaction

Record a compaction boundary by APPENDING a summary entry (append-only).

Ports pi's appendCompaction (session-manager.ts:990): a new compaction entry is appended as a child of the current tip — carrying first_kept_id / summary / tokens_saved — and the tip advances to it. Nothing already on the log is mutated. The read-time fold (_build_active_path / ConversationTree.context_for) splices the summary in and drops the pre-boundary prefix, so get_active_messages yields [summary, <first_kept … tip>] just as before.

This replaces the former re-parent-and-rewrite: appending restores the append-only invariant and crash-safety (a torn "w" rewrite could destroy a session) and keeps the pre-compaction history addressable — navigating behind the boundary reveals it again, nothing was deleted.

This is the structural counterpart to compaction.compact (which only computes the summary); the compaction engine is unchanged — only where/how the boundary is recorded differs (append, not rewrite).

Parameters

  • first_kept_entry_id: str — Id of the first entry to retain in full (an existing entry; the boundary is resolved at read time).
  • summary: str — The generated summary text replacing the compacted prefix.
  • compacted_entry_ids: list[str] | None = None — Ids replaced by the summary (recorded as metadata).
  • tokens_saved: int = 0 — Estimated tokens saved (recorded as metadata).

Returns

The id of the appended compaction entry.

Raises

  • KeyError — If first_kept_entry_id is not in the session.

clone

clone(entry_id: str) -> str

tau_agent_core.session_manager.SessionManager.clone

Duplicate the active path at entry_id into a new session.

Parameters

  • entry_id: str — (no description)

Returns

Path to the new session file.

fork

fork(entry_id: str, position: Literal['before', 'at'] = 'before') -> str

tau_agent_core.session_manager.SessionManager.fork

Create a new session from a specific entry.

Parameters

  • entry_id: str — The entry to fork from
  • position: Literal['before', 'at'] = 'before' — "before" copies entries before entry_id, "at" copies entry_id and entries after it

Returns

Path to the new session file.

get_active_messages

get_active_messages() -> list[dict]

tau_agent_core.session_manager.SessionManager.get_active_messages

Get messages for the current active path (tree-aware).

Returns messages by following the parent_id chain from the current active_entry_id back to root, skipping compacted entries and including compaction summaries.

in_memory

in_memory(cwd: str | None = None) -> SessionManager

tau_agent_core.session_manager.SessionManager.in_memory

Create an in-memory session manager (no file persistence).

Parameters

  • cwd: str | None = None — (no description)

list_all

list_all() -> list[SessionInfo]

tau_agent_core.session_manager.SessionManager.list_all

List all sessions across all directories.

list_sessions

list_sessions() -> list[SessionInfo]

tau_agent_core.session_manager.SessionManager.list_sessions

List sessions for the current working directory.

Returns sessions sorted by creation timestamp (newest first).

Note: named list_sessions rather than list so it does not shadow the builtin list in this module's annotations.

load

load(session_path: str) -> SessionState

tau_agent_core.session_manager.SessionManager.load

Load a session from a JSONL file.

Reads all entries from the file and returns a SessionState.

Parameters

  • session_path: str — (no description)
navigate(entry_id: str | None) -> SessionState

tau_agent_core.session_manager.SessionManager.navigate

Navigate to a specific entry in the tree.

Updates the active entry ID and returns the session state at that position.

Parameters

  • entry_id: str | None — (no description)

new_session

new_session(model_id: str | None = None) -> str

tau_agent_core.session_manager.SessionManager.new_session

Create a new session file. Returns session path.

Creates a JSONL file with a single session entry as the root.

Parameters

  • model_id: str | None = None — (no description)

save

save(state: SessionState) -> None

tau_agent_core.session_manager.SessionManager.save

Save session state — append new entries to the session file.

Parameters

  • state: SessionState — (no description)

tau_agent_core.session.SessionState

tau_agent_core.session.SessionState

Read-only session state.

Represents the current state of a session, including metadata about the session's lifecycle and current condition.

created_at

tau_agent_core.session.SessionState.created_at: int

Session creation timestamp (ms since epoch)

is_compacted

tau_agent_core.session.SessionState.is_compacted: bool

Whether the session has been compacted

message_count

tau_agent_core.session.SessionState.message_count: int

Number of messages in the session

session_id

tau_agent_core.session.SessionState.session_id: str

The session's unique identifier

status

tau_agent_core.session.SessionState.status: Literal['idle', 'running', 'aborting', 'error']

Current status ("idle", "running", "aborting", "error")

turn_count

tau_agent_core.session.SessionState.turn_count: int

Number of turns completed

updated_at

tau_agent_core.session.SessionState.updated_at: int

Last update timestamp (ms since epoch)

tau_agent_core.session_manager.SessionState

class SessionState(entries: list[dict] = list(), session_path: str = '', active_entry_id: str | None = None, model: str | None = None, model_name: str | None = None, cwd: str | None = None, system_prompt: str | None = None, session_name: str | None = None)

tau_agent_core.session_manager.SessionState

State of a loaded session (return type of load()).

Constructor parameters

  • entries: list[dict] = list() — All entries for this session (loaded from file or memory)
  • session_path: str = '' — Path to the JSONL session file
  • active_entry_id: str | None = None — Current position in the tree (None = root)
  • model: str | None = None — Model identifier used for this session
  • model_name: str | None = None — Human-readable model name
  • cwd: str | None = None — Working directory when session was created
  • system_prompt: str | None = None — System prompt used
  • session_name: str | None = None — Human-readable session name

SessionStats

class SessionStats(context: ContextUsageEstimate, context_window: int, context_headroom: int, compaction_settings: CompactionSettings, last_compaction: CompactionRecord | None, usage: dict[str, Any] | None)

tau_agent_core.agent_session.SessionStats

Everything a caller needs to decide whether and when to compact.

Composed by :meth:AgentSession.get_session_stats from five reads that were each already public. It exists because the composition was not: the RPC get_session_stats verb assembled it inside the wire layer, so a head that was not the wire had to assemble its own and could reach a different answer.

context is an ESTIMATE and usage is what the provider reported for the last completion. They are not the same measurement and neither replaces the other: a caller that wants what the model was actually charged for reads usage; a caller deciding whether the NEXT turn will fit reads context, which covers messages appended since that completion.

Constructor parameters

  • context: ContextUsageEstimate — compaction.estimate_context_tokens over the active path.
  • context_window: int — The active model's window, from :meth:AgentSession.get_model.
  • context_headroom: int — context_window - context.tokens. Negative when the path is already over budget — an honest number, never clamped.
  • compaction_settings: CompactionSettings — The settings in force, as a copy.
  • last_compaction: CompactionRecord | None — The newest compaction entry, or None if this session has never compacted.
  • usage: dict[str, Any] | None — :meth:AgentSession.get_usage — None before the first completion.

ToolResultEntry

tau_agent_core.session.ToolResultEntry

Tool result entry: stores tool execution result.

Reference: SUBPHASE-0.0.md, "6. Session Entry JSON Schema" section.

content

tau_agent_core.session.ToolResultEntry.content: list[dict[str, Any]]

No description. This object is marked but undocumented.

id

tau_agent_core.session.ToolResultEntry.id: str

No description. This object is marked but undocumented.

is_error

tau_agent_core.session.ToolResultEntry.is_error: bool

No description. This object is marked but undocumented.

parent_id

tau_agent_core.session.ToolResultEntry.parent_id: str | None

No description. This object is marked but undocumented.

timestamp

tau_agent_core.session.ToolResultEntry.timestamp: int

No description. This object is marked but undocumented.

tool_call_id

tau_agent_core.session.ToolResultEntry.tool_call_id: str

No description. This object is marked but undocumented.

tool_name

tau_agent_core.session.ToolResultEntry.tool_name: str

No description. This object is marked but undocumented.

type

tau_agent_core.session.ToolResultEntry.type: Literal['toolResult']

No description. This object is marked but undocumented.

TreeNode

class TreeNode(id: str, parent_id: str | None, kind: str, role: str | None, preview: str, is_leaf: bool, children: list[TreeNode] = list())

tau_agent_core.conversation_tree.TreeNode

A node in the browsable session tree (pi SessionTreeNode).

Constructor parameters

  • id: str — (no description)
  • parent_id: str | None — (no description)
  • kind: str — (no description)
  • role: str | None — (no description)
  • preview: str — (no description)
  • is_leaf: bool — (no description)
  • children: list[TreeNode] = list() — (no description)

View

class View(name: str, state: dict[str, Any] | None = None, unavailable_because: str | None = None)

tau_agent_core.flows.View

A named surface only a head can open.

The third thing a dispatched gesture can be, beside a step and a performed mutation. /tree and /extensions are the two (:data:~tau_agent_core.capabilities.VIEW_COMMANDS), and what a view IS stays head-local: this record says which one was asked for, and either carries the state a head would draw it from or a written reason it cannot be drawn here.

Exactly one of state and unavailable_because is set, and __post_init__ enforces it. A View with neither is a head being handed nothing and told nothing, which is the silent no-op :class:~tau_agent_core.commands.UnsupportedCommandError exists to prevent; one with both is two answers to the same question.

Constructor parameters

  • name: str — The view's name, a key of :data:~tau_agent_core.capabilities.VIEW_COMMANDS.
  • state: dict[str, Any] | None = None — What a head draws the view from. None everywhere today — no capability projects the session tree yet (docs/VSCODE-HEAD.md §6), and this is the spot that payload lands in when one does, with no change to the union.
  • unavailable_because: str | None = None — Why no state rides with this, in a sentence a head can print. A head that has its own view of that name ignores it and opens it; a head that has none prints it and does nothing else. Not a fallback: it is the same idiom the RPC table's seven declined_because entries already use.

admission_reason

admission_reason(messages: list[dict[str, Any]]) -> str | None

tau_agent_core.tree_surgery.admission_reason · since 0.9.7

Why this message sequence would be rejected as a turn, or None.

The plan-level counterpart of :meth:~tau_agent_core.conversation_tree.ConversationTree.fork_admission_reason, which asks the same question of a real path. Two faults, and the first is the one a hand-built selection produces:

  • an orphan result — a toolResult whose tool_call_id no preceding assistant message declared. Copying a result without its call, or eliding the call out from under it, both land here;
  • a pending call — the sequence ends on an assistant message with tool calls that nothing answered. This is what fork_admission_reason rejects, and it matters here for the same reason: the branch's tip becomes the cursor, so the next turn starts from exactly this prefix.

Parameters

  • messages: list[dict[str, Any]] — The composed message list, root→leaf.

Returns

A sentence naming the offending call, or None when the sequence is turn-complete.

agent_spec_in_force

agent_spec_in_force(entries: list[dict[str, Any]], leaf_id: str | None) -> str | None

tau_agent_core.session_log.agent_spec_in_force

The id of the agent_spec record governing leaf_id, or None.

What a splice anchor's agentSpecId must be set to (TREE-BROWSER-AS-EDITOR.md §8.3): the nearest agent_spec customEntry among leaf_id's ANCESTORS, walking parentId leaf→root.

Ancestry, not "the last one this session wrote", for the same reason ConversationTree._previous_agent_spec uses it and docs/LANE-REMOVAL.md §1 removed the tag that pretended otherwise: a leaf's frame is its ancestor chain and nothing else. The two answers diverge exactly where it matters — an elide the tree browser aims at a historical anchor is governed by whatever spec was in force there, which may be two set_model swaps behind the session's current one, and a spec written on a sibling branch never governed this path at all.

Lives here beside :func:resolve_cursor, and for the same reason: it is part of the entry algebra every SessionLog implementation must agree on exactly, not a property of any one durability layer. Implemented as a plain parentId walk rather than through ConversationTree so this module keeps its zero-dependency position under the tree, and so the two callers (one in tau-agent-core, one in tau-coding-agent) share one spelling.

Returns None when no ancestor is an agent_spec — an honest answer, and a reachable one: a pi-imported log has no such node, and neither does a store driven directly rather than through AgentSession. §11.3's "no defaults" rule is what keeps that answer distinct from a caller who never looked.

Parameters

  • entries: list[dict[str, Any]] — (no description)
  • leaf_id: str | None — (no description)

bind_command_args

bind_command_args(flow: str, raw: str) -> dict[str, Any]

tau_agent_core.flows.bind_command_args

Bind the text typed after a slash command to the flow's argument.

The one-line half of running a flow from a command line, written here rather than in each head so two heads cannot come to accept different words for the same gesture — the reason :func:bind_text is here, one level down.

A flow that takes no argument ignores raw, and so does an empty raw: both produce {}, which :func:next_step turns into the flow's first step or into a :class:Ready, depending on whether anything was required. Stray text after an argument-less command is therefore still discarded, which is a known gap (docs/SLASH-COMMANDS.md §4) this function does not close.

Parameters

  • flow: str — The flow's name.
  • raw: str — Everything the reader typed after the command word.

Returns

The bound arguments, ready for :func:next_step.

Raises

  • UnknownFlowError — No flow has that name.
  • ValueError — raw is not a value of the argument's domain, or the flow takes more than one argument — there is no rule for splitting one typed line across two, and guessing one is how a head would silently bind the wrong halves.

bind_text

bind_text(argument: Argument, text: str) -> Any

tau_agent_core.flows.bind_text

Turn what a person TYPED into the value argument's mutation takes.

A head that reads a line of text has a string; a mutation taking enabled: bool does not. Written here, once, because the alternative is every head inventing its own answer to "does /autocompact on mean true" — and then two heads accepting different words for the same flow. The words a fixed-value domain accepts are the words it DECLARES, and nothing else.

Four cases, by what the domain says about itself. A domain with fixed values matches case-insensitively against exactly those, and the boolean domain additionally hands back a real bool, since its two values name Python's. The free integer and number domains parse. Every other domain — free text, and the ones with an enumerator, whose values are strings a caller sends back verbatim — passes the text through unchanged.

Not a validator for enumerated domains: whether a3f9c1 names an entry is a question about a live tree, and :func:enumerate_domain is what answers it. This converts a TYPE, and refuses only where the type itself is wrong.

Parameters

  • argument: Argument — The argument being bound, for its domain and its name.
  • text: str — What the person typed, already stripped of the command word.

Returns

The bound value, ready to go into :func:next_step's bound mapping.

Raises

  • ValueError — The text is not a value of that domain, naming what is acceptable. Fail-Early: coercing an unrecognised word to False would silently turn /autocompact yes into "off".

branch_refusal_reason

branch_refusal_reason(tree: ConversationTree, ids: Iterable[str], *, drop_context: bool) -> str | None

tau_agent_core.tree_surgery.branch_refusal_reason · since 0.9.7

Why the marked selection cannot become a branch, or None if it can.

Every refusal the commit would raise, computed while the reader can still see the tree they marked. Three of them:

  • a marked node whose kind cannot be copied (:data:COPYABLE_KINDS) — a navigate or a splice anchor carries an id pointing at a path the copy will not be on;
  • a system message among the COPIES, which would mint a second system prompt in the middle of the branch beside the one every fold carries anyway. As the root-most mark it is a keep and stays legal — that is how "hang these messages straight off the system prompt" is said;
  • a composed path that is not turn-complete (:func:admission_reason).

The last should be rare: the browser expands a mark to its :func:tool_group, so a reader cannot easily select half a tool call. It is still checked, because "rare" and "impossible" are different and the log is what pays the difference.

Parameters

  • tree: ConversationTree — The tree the marks belong to.
  • ids: Iterable[str] — The marked entry ids, in any order.
  • drop_context: bool — As :func:plan_branch — it changes what the composed path is, and therefore what turn-completeness means for it.

Returns

A sentence naming the problem, or None.

Raises

  • ValueError — The selection is empty, or an id names no entry.

commit_branch

commit_branch(session: SessionLog, ids: Sequence[str], *, drop_context: bool) -> list[dict]

tau_agent_core.tree_ops.commit_branch

Build a branch out of the marked entries and continue on it.

The durable half of TREE-BROWSER-AS-EDITOR.md §6. tree_surgery decides what the branch IS — which marks are kept in place, which are minted as copies, whether an elide follows — and this performs it, in the order §6.3 fixes:

  1. move the leaf to the plan's attach point (the last kept mark);
  2. mint each copy with append_at, parented at the previous one;
  3. move the leaf onto the last minted entry;
  4. append the elide, when the caller asked to keep only the selection.

Step 2 is invisible until step 3 lands. append_at does not move the leaf, so a mint that fails partway leaves orphan entries hanging off the attach point and the cursor exactly where it was — the commit is atomic from the cursor's point of view, which is the property §6.3 is built around and the reason the copies are not appended one gesture at a time.

Nothing is re-parented and nothing is erased. I1 holds because every entry's parentId is still written once, at append (§6.1's argument for why a plan exists at all rather than a sequence of edits).

Parameters

  • session: SessionLog — The session log to write to.
  • ids: Sequence[str] — The marked entry ids, in any order — tree_surgery puts them into tree order.
  • drop_context: bool — Whether the branch keeps only the selection. True appends an elide resuming at the root-most mark, so the context becomes the system prompt plus the branch. False leaves everything above the attach point in context.

Returns

ConversationTree.context_for(cursor) — the new flat message list, the same re-render seam :func:elide_span and :func:navigate use.

Raises

  • ValueError — The selection is empty, names an unknown entry, contains an entry no branch can carry, or composes a path that is not turn-complete. Checked before the first append, so a refusal leaves the log byte-identical.

copy_of

copy_of(entry: dict[str, Any]) -> tuple[str, dict[str, Any]]

tau_agent_core.tree_surgery.copy_of · since 0.9.7

The (kind, payload) an append_at needs to re-mint entry elsewhere.

A copied message keeps type: "message" rather than gaining a kind of its own (§7.1): a copy IS an ordinary message on an ordinary path, and every existing walker should treat it as one without being taught anything. The provenance is one extra field, copiedFrom, which joins the JMFTS store's cross-reference fields so a query can dedupe two documents with identical text.

A branch_summary copy drops fromId. That field names the branch point the summary was written at, which the copy is not at; carrying it over would state a relation to a node the copy has no edge to.

Parameters

  • entry: dict[str, Any] — The source entry, whose kind must be in :data:COPYABLE_KINDS.

Returns

The entry type and the payload to append.

Raises

  • ValueError — The entry's kind cannot be copied.

elide_span

elide_span(session: SessionLog, anchor_id: str, first_kept_id: str) -> list[dict]

tau_agent_core.tree_ops.elide_span

Fold a span out of session's context and return the new context.

elide is the summary-less generalization of the compaction anchor (W3, NODE-ADDRESSABLE-AGENTS.md). Synchronous, unlike :func:summarize_and_navigate: there is no summary, therefore no model call and nothing to await. An async def with no await would advertise an I/O boundary this operation does not have.

Two ids, because an elide is not a branch point. anchor_id is where the fold jumps FROM — the elide entry is appended as its child, so the anchor becomes the end of the kept region and the new tip. first_kept_id is where it jumps TO: ConversationTree._active_path_entries emits the anchor, then the anchor's ancestors from firstKeptId onward. Everything on that path BEFORE firstKeptId is the elided span.

first_kept_id must therefore be the anchor itself or one of its ancestors, never a descendant. That direction is not a style choice, it is what the fold's forward scan over path[:anchor_idx] can reach: a boundary the scan never finds leaves found False forever, so the fold emits the anchor and NOTHING else — an empty context, silently, with no error. Hence the check here, before either append: append_elide's own Fail-Early only proves the id names an entry, not that it names a reachable one, and the unreachable case is the more damaging of the two.

Refusing a no-op elide is the other check. An elide whose span is empty (first_kept_id already the first entry the fold keeps) persists a node that changes nothing about the context it was created to change — the silent-no-op anti-pattern, indistinguishable to the user from a successful fold. The core's append_elide deliberately permits it (an anchor on a root-level entry is a pinned contract case); this operation, where someone just asked for a span to disappear, does not.

Nothing is erased: the navigate/elide pair are appends like any other, and every entry the fold now skips is still in entries() (Decision 7 / T5).

Parameters

  • session: SessionLog — The session log to fold.
  • anchor_id: str — The entry the fold jumps from, which becomes the new tip.
  • first_kept_id: str — The entry the fold resumes at. The anchor itself, or one of its ancestors.

Returns

ConversationTree.context_for(cursor) — the flat message list a head swaps into its transcript and re-renders, exactly as :func:navigate does.

Raises

  • ValueError — An unknown anchor or resume point, a resume point that is not on the anchor's path, or a span that would hide nothing. All three are checked before the first append, so a refusal leaves the log byte-identical.

entries_to_messages

entries_to_messages(entries: list[dict[str, Any]]) -> list[dict[str, Any]]

tau_agent_core.conversation_tree.entries_to_messages · since 0.9.7

Convert already-folded path entries into the loop's message list.

The second half of :meth:ConversationTree.context_for, split out so a caller holding a hypothetical entry list — the branch a plan would produce, before any of it is appended (tau_agent_core.tree_surgery) — converts it by the same rules the live fold uses. A second conversion written beside the planner would be a second answer to "what will the model see", and the whole point of checking a plan before committing it is that the two cannot differ.

Mirrors SessionManager.get_active_messages (session_manager.py:191-221).

Parameters

  • entries: list[dict[str, Any]] — Folded path entries, root→leaf — the output of :meth:ConversationTree.context_entries, or a planned equivalent.

Returns

The messages those entries contribute, in order. Entry kinds that carry no message at all (navigate, customEntry, model_change) contribute nothing, and an elide contributes nothing by design: it is a splice anchor with no payload to render.

enumerate_domain

enumerate_domain(domain: str, *, session: Any = None, runtime: Any = None, scope: MessageIdScope | None = None, cursor: str | None = None, query: str = '', limit: int = _ENUMERATION_LIMIT) -> DomainValues

tau_agent_core.flows.enumerate_domain

The values legal for domain right now, each with a readable label.

The other half of the flow loop. A head calls this with the domain a :class:FlowStep handed it and renders what comes back — a completion list, a select field, a picker — without knowing what the domain means.

Dispatches to the readers that already exist rather than reimplementing any of them, so a listing here and the corresponding RPC verb cannot disagree about what exists. A free domain has no value set and returns none, with total 0; a domain whose values are fixed returns them without touching either object.

Parameters

  • domain: str — The domain's name, a key of :data:~tau_agent_core.capabilities.DOMAINS.
  • session: Any = None — The :class:~tau_agent_core.agent_session.AgentSession to read models, extensions and the session tree from.
  • runtime: Any = None — The :class:~tau_agent_core.agent_session_runtime.AgentSessionRuntime to read the session catalog and the working directory from. Separate from session because both live there, not on the session. path needs it as much as session_id does: completing against the process's own directory instead of the runtime's answers a different question than the one that was asked, so its absence raises rather than falling back.
  • scope: MessageIdScope | None = None — For message_id, which entries are candidates.
  • cursor: str | None = None — For a scoped message_id, the entry the scope is relative to.
  • query: str = '' — Filter text. Honoured by the domains whose readers take one; a domain with a small fixed set ignores it.
  • limit: int = _ENUMERATION_LIMIT — How many values to return at most.

Returns

class:DomainValues carrying the values and the true total.

Raises

  • KeyError — No domain has that name.
  • ValueError — The domain needs an object this call did not supply — a session or a runtime. Fail-Early: returning an empty list would say "there are none" when the truth is "nothing was asked".

flow_arguments

flow_arguments(flow: str) -> tuple[Argument, ...]

tau_agent_core.flows.flow_arguments

Every argument flow declares, in the order it asks for them.

A head building a whole form needs the list, not just the next one :func:next_step blocks on. Pure, and the same lookup next_step uses, so the two cannot come to disagree about what a flow takes.

Parameters

  • flow: str — The flow's name.

Returns

The declared arguments. Empty for a flow that takes none.

Raises

  • UnknownFlowError — No flow has that name.

flow_form_spec

flow_form_spec(flow: str, bound: dict[str, Any] | None = None, *, options: dict[str, list[str]] | None = None) -> dict[str, Any]

tau_agent_core.flows.flow_form_spec

The arguments flow still needs, as a ui.form spec.

The join between the two halves of τ's argument vocabulary. Flows describe an argument as a :class:~tau_agent_core.capabilities.Domain plus a cardinality; :func:~tau_agent_core.extension_types.validate_form_spec describes one as a field kind. :class:~tau_agent_core.capabilities.Argument's docstring has always claimed the pair (domain, cardinality) replaces the five field kinds — this is that claim as a function, so one form renderer per head serves built-in flows, extension-declared flows and an extension's own ui.form alike.

Asks for exactly the arguments :func:next_step would block on: required and not yet bound. A flow whose remaining arguments are all optional produces no form, because it is already runnable and a dialog in front of it would be a confirmation step τ does not have (see this module's "Partial arguments are the dry run").

Parameters

  • flow: str — The flow's name.
  • bound: dict[str, Any] | None = None — The arguments bound so far. None and {} are the same thing.
  • options: dict[str, list[str]] | None = None — The legal values for any argument whose domain renders as a select, keyed by ARGUMENT name — from :func:enumerate_domain, which needs the live objects this function deliberately does not take. Pure: the caller reads, this shapes.

Returns

A spec {"title": …, "fields": [...]} that func:~tau_agent_core.extension_types.validate_form_spec accepts, or an empty dict when the flow needs nothing.

Raises

  • UnknownFlowError — No flow has that name.
  • ValueError — An argument renders as a select and options carries no non-empty list for it. Fail-Early: degrading a select to a free text box would silently accept values the domain does not admit, and offering an empty select would be a question with no answers.

last_assistant_text

last_assistant_text(messages: list[dict[str, Any]]) -> str | None

tau_agent_core.messages.last_assistant_text

The most recent assistant message's text in messages, or None.

A pure function over a message list, so a caller holding a transcript can ask without holding a session. :meth:~tau_agent_core.agent_session.AgentSession .get_last_assistant_text is this applied to the session's active path, and is what the get_last_assistant_text capability performs.

Two rules the shape is not obvious about.

A message that was aborted before it produced a single block — stop_reason == "aborted" AND empty content — is skipped as though it never happened, so an abort-and-retry does not hide the last real answer. An aborted message that DID say something is not skipped; its text still counts.

Only type == "text" blocks contribute, concatenated in order with no separator. A thinking block is not the answer, and a tool-call block has no text to give.

Parameters

  • messages: list[dict[str, Any]] — The transcript, oldest first.

Returns

The concatenated text, stripped, or None. None covers both "no assistant message yet" and "the last one carried no text" (a pure tool-call turn); the two are deliberately not distinguished, and a caller that must tell them apart looks at messages itself.

navigate(session: SessionLog, target_id: str | None) -> list[dict]

tau_agent_core.tree_ops.navigate

Move session's cursor to target_id and return the new context.

Appends a navigate entry — zero LLM calls. The abandoned branch drops out of context via the parentId walk but stays on disk, append-only and still browsable. A target_id that is already the cursor is a no-op that still returns the context, so a caller need not check first.

Typed to the SessionLog Protocol rather than a concrete store: this touches only cursor, entries() and append_navigate, all three of which are on the Protocol, so an in-memory, file or database-backed log works here unchanged.

Parameters

  • session: SessionLog — The session log to move.
  • target_id: str | None — The entry to move the cursor onto, or None for pre-root — the next append then starts a branch above every existing entry.

Returns

ConversationTree.context_for(cursor) — the flat message list a head swaps into its transcript and re-renders.

next_step

next_step(flow: str, bound: dict[str, Any] | None = None, cursor: str | None = None) -> FlowStep | Ready

tau_agent_core.flows.next_step

The next argument flow needs, or the mutation it is ready to perform.

The one entry point a head needs to run a flow it has no special knowledge of. Pure: it reads no session, performs nothing, and returns the same answer for the same inputs.

Only REQUIRED arguments block. An optional argument is never demanded — a caller that wants to offer one reads it off the flow's own arguments — which is what makes a flow with nothing but optional arguments run immediately, the way a command with only optional flags does.

Parameters

  • flow: str — The flow's name.
  • bound: dict[str, Any] | None = None — The arguments bound so far. None and {} are the same thing: the flow's first step.
  • cursor: str | None = None — The entry a message_id argument's scope is relative to. Required of the caller rather than read off a session, for the reason resolve_command takes extension_commands as a parameter: it keeps this callable from a head that is peeking, a runtime that is deciding, and a test with neither. A caller stepping a sub-agent's flow passes THAT agent's cursor.

Returns

class:FlowStep while a required argument is unbound, otherwise a class:Ready.

Raises

  • UnknownFlowError — No flow has that name.

open_branch

open_branch(log: SessionLog, parent_id: str | None, *, label: str) -> BranchView

tau_agent_core.session_log.open_branch

Open a new branch lane over log, rooted at parent_id.

parent_id chooses the sub-agent's inherited context (the fold walks up from it), and must name a real entry — Fail-Early, since a dangling branch root would give the sub-agent an empty or wrong context with no error.

The lane id is freshly generated per call, NOT derived from parent_id. Two sub-agents spawned from the SAME parent (the common fan-out shape — several evaluators over one retrieval result) would otherwise be indistinguishable on the live branch_event channel, and their output would interleave in one render lane. It is a runtime routing key only; nothing durable carries it.

Parameters

  • log: SessionLog — (no description)
  • parent_id: str | None — (no description)
  • label: str — (no description)

paste_refusal_reason

paste_refusal_reason(tree: ConversationTree, plan: PastePlan) -> str | None

tau_agent_core.tree_surgery.paste_refusal_reason · since 0.9.7

Why the pasted subtree would be malformed where it lands, or None.

One fault: a copied toolResult whose call is on neither the target's path nor the copied run above it. Every message below such a result is unusable, and the reader cannot see why from the rows.

A copied line that ENDS on an unanswered tool call is deliberately not refused. A paste does not move the cursor, so no turn starts there until someone navigates onto it — and navigating onto a node with a pending call is a state an ordinary interrupted turn reaches too. Refusing it here would invent a rule the rest of the browser does not apply.

Parameters

  • tree: ConversationTree — The tree the plan was made against.
  • plan: PastePlan — The paste plan to check.

Returns

A sentence naming the offending result, or None.

paste_subtree

paste_subtree(session: SessionLog, source_id: str, target_id: str) -> list[str]

tau_agent_core.tree_ops.paste_subtree

Re-create the subtree at source_id under target_id.

The durable half of TREE-BROWSER-AS-EDITOR.md §7. Every copied entry is a new entry carrying copiedFrom, minted with append_at so the paste never moves the leaf: a paste edits the TREE, and what the model sees changes only when someone navigates onto the copy. That split is why this returns ids rather than a message list — nothing about the current context changed.

Parents are minted before children (plan_paste orders them that way), and a source→new id map re-hangs each child under its copied parent, so the copy keeps the original's shape including its forks.

Parameters

  • session: SessionLog — The session log to write to.
  • source_id: str — The copied node — the root of the subtree.
  • target_id: str — The entry the copy hangs from.

Returns

The ids minted, in the order they were appended. The first is the copy of source_id itself.

Raises

  • ValueError — An unknown id, a source whose kind cannot be copied, a target inside the source's own subtree, or a copied tool result whose call is on neither the target's path nor the copied run. Checked before the first append.

plan_branch

plan_branch(tree: ConversationTree, ids: Iterable[str], *, drop_context: bool) -> BranchPlan

tau_agent_core.tree_surgery.plan_branch · since 0.9.7

Work out the branch a marked selection asks for.

The keep/copy split is read off the tree (§6.3 step 2): the longest run of marks starting at the root-most one where each is the real parentId child of the one before is KEPT, and everything after the first gap is COPIED. A selection that is already a contiguous ancestor chain therefore mints nothing at all and the commit is a cursor move plus, at most, an elide — case A, the only form that preserves every id's identity.

Parameters

  • tree: ConversationTree — The tree the marks belong to.
  • ids: Iterable[str] — The marked entry ids, in any order (ordered by :func:selection_order).
  • drop_context: bool — Whether the branch should keep only the selection. True plans an elide resuming at the root-most mark, so the context becomes the system prompt plus the branch; False leaves everything above the attach point in context.

Returns

The plan. Ask :func:branch_refusal_reason first — this function assumes the selection is legal and describes what would happen, rather than judging it.

Raises

  • ValueError — The selection is empty, or an id names no entry.

plan_paste

plan_paste(tree: ConversationTree, source_id: str, target_id: str) -> PastePlan

tau_agent_core.tree_surgery.plan_paste · since 0.9.7

Work out the subtree copy a paste asks for.

The whole subtree, not one node: a paste re-creates what hangs below the copied node, including its forks, because a conversation branch's shape is part of what was copied. Parents come before children, so the commit can walk the mints once keeping a source→new id map.

Parameters

  • tree: ConversationTree — The tree both ids belong to.
  • source_id: str — The copied node — the root of the subtree.
  • target_id: str — The entry the copy hangs from.

Returns

The plan.

Raises

  • ValueError — Either id is unknown, the source's kind cannot be copied, or the target is inside the source's own subtree — that last one would put the copy and the original on one root→leaf path, where a duplicated tool_call_id stops meaning one call.

planned_messages

planned_messages(tree: ConversationTree, plan: BranchPlan) -> list[dict[str, Any]]

tau_agent_core.tree_surgery.planned_messages · since 0.9.7

The message list the branch would hand the model, if it were committed.

Built from the same fold the live session uses (:func:entries_to_messages), so a plan that validates here cannot fail differently once appended. The copies contribute their source entries' messages, because that is what a copy IS — the same content at a new id.

Parameters

  • tree: ConversationTree — The tree the plan was made against.
  • plan: BranchPlan — The plan to project.

Returns

The composed root→leaf message list.

resolve_cursor

resolve_cursor(entries: list[dict[str, Any]]) -> str | None

tau_agent_core.session_log.resolve_cursor

Resolve the persisted cursor (leaf pointer) from the entry log.

Latest-wins: a trailing navigate entry points at its targetId (None = pre-root); any other kind points at itself. pi-style logs carry no navigate entries, so the cursor is simply the last entry — identical to pi's "fall back to last entry" on load (session-manager.ts:855-859). This is pi parity restored: τ briefly filtered lane-tagged (branchOf) entries out of this decision, and no longer does.

Lives here, not on a concrete store, because every SessionLog implementation (in-memory, file, and any database-backed one) must agree on it exactly — the cursor is part of the entry algebra, not of any one durability layer.

A guarantee was dropped here, deliberately (docs/LANE-REMOVAL.md §2). The lane filter existed so that a sub-agent's append landing last before a crash could not make the next load resume inside that branch. τ is an interactive agent, not a service that must survive pkill at an arbitrary instant with perfect consistency: if a crash lands you on a branch leaf, the transcript is visible, the tree browser moves you, and nothing is corrupted. The filter also encoded a "main agent with helpers" model that τ does not hold — one agent moves forwards, backwards and sideways through its own history, and there "last write wins" is usually the intended continuation rather than an accident (§2).

Parameters

  • entries: list[dict[str, Any]] — (no description)

selection_order

selection_order(tree: ConversationTree, ids: Iterable[str]) -> tuple[str, ...]

tau_agent_core.tree_surgery.selection_order · since 0.9.7

Put marked ids into the order the browser draws them.

The reader marks nodes in whatever order they find them; a branch built from "the order I happened to click" would be a different conversation depending on how the reader navigated. Row order is the tree's own order — a preorder walk of :meth:ConversationTree.tree, whose children are already sorted by timestamp — so the branch reads down the screen exactly as it was selected.

Parameters

  • tree: ConversationTree — The tree the ids belong to.
  • ids: Iterable[str] — The marked entry ids, in any order. Duplicates collapse.

Returns

The same ids, root-most first, in row order.

Raises

  • ValueError — An id names no entry in this tree. Fail-Early: a plan built around a dangling id would mint a branch missing a message the reader asked for, and say nothing.

session_log_is_addressable

session_log_is_addressable(log: object) -> bool

tau_agent_core.session_log.session_log_is_addressable

Whether a later switch_session could reach the session log holds.

A session is addressable exactly when it declares a durable location and that location is set, because that is also what puts it in the store's listing: SessionCatalog.list is what the RPC list_sessions verb returns and what resolve_ref resolves against. So this is not an opinion — it is "list_sessions will return this id", answerable at the moment the id is minted.

An ephemeral session (create_ephemeral — the file store's path-less Session, the JMFTS store's _EphemeralConversationSession) declares neither attribute and is therefore False.

A predicate rather than a raise: a caller that was ASKED for an unpersisted session made the right one. What it must not do is describe it as addressable. The raising form is rpc.commands.require_durable_session, which asks this same question of the verbs that append (docs/RPC-PROTOCOL.md, D-7 rule 1).

Parameters

  • log: object — The session log to ask about.

Returns

Whether the session is one the store can hand back later.

slash_vocabulary

slash_vocabulary() -> dict[str, str]

tau_agent_core.capabilities.slash_vocabulary

Every built-in slash command, mapped to what it does.

The projection :data:tau_agent_core.commands.FRONTEND_COMMANDS is built from, so the flow table and the slash vocabulary cannot drift apart.

Ordering is compact, the view commands, then the remaining flows, which is the order the vocabulary already had — completion lists and the RPC get_commands listing are both observably ordered, so the projection preserves what callers already see rather than re-sorting on a new principle.

Returns

A fresh dict of command name to one-line description. Fresh rather than shared, because it goes to callers that hold it.

summarize_and_navigate

summarize_and_navigate(session: SessionLog, target_id: str, model: Any, *, api_key: str | None = None, custom_instructions: str | None = None) -> tuple[list[dict], dict[str, int]]

tau_agent_core.tree_ops.summarize_and_navigate

Summarize the subtree at target_id and splice the summary onto the path.

The summarizing arm of what used to be navigate_tree(summarize=True). Extracts the abandoned subtree's text (ConversationTree.subtree_text), summarizes it (session_manager.summarize_branch, which raises on a failed or empty summary rather than returning one), and appends a branch_summary entry parented at the branch point (Decision 5, fix 1).

Separate from :func:navigate rather than a flag on it, because the two differ in what they cost: this one makes a completion call, so it is a coroutine and it hands back the tokens it spent. The caller banks them — AgentSession.record_side_usage on the live path — because this function holds no session object to bank them against.

Parameters

  • session: SessionLog — The session log to write the summary into.
  • target_id: str — The branch point. The subtree BELOW it is what gets summarized, and the branch_summary entry is parented at it.
  • model: Any — The model config the summarizer runs against.
  • api_key: str | None = None — The key for that model's provider, when it needs one.
  • custom_instructions: str | None = None — Extra guidance for the summarizer's SYSTEM prompt (the tree browser's mode 3).

Returns

A pair: the re-rendered context (ConversationTree.context_for) and the summarizer's usage, for the caller to add to its side ledger.

Raises

  • ValueError — target_id names no entry (checked first, before the model call — subtree_text answers "" for an unknown id, so without this the operation would spend a completion summarizing nothing and append the result), or the summarizer returned nothing usable — the second raised by session_manager.summarize_branch, not fabricated into an empty summary here.

summarize_branch

summarize_branch(branch_text: str, model: Any, *, api_key: str | None = None, custom_instructions: str | None = None) -> tuple[str, dict[str, int]]

tau_agent_core.session_manager.summarize_branch

Summarize an abandoned branch's text into a concise summary.

The summarizer engine behind the tree-browser's "Summarize" modes. branch_text is produced by ConversationTree.subtree_text (§2.1) and passed in; the caller (tree_ops.summarize_and_navigate, §3.3) then persists the result as a branch_summary entry. Mode 3's custom_instructions are threaded into the summarizer's SYSTEM prompt.

Fail-Early (§3.1): the previous truncated-raw-text fallback is GONE — a failed, aborted, or empty LLM response RAISES rather than fabricating a summary from raw text. No branch-summary is ever silently invented.

Reference: SESSION-TREE-IMPLEMENTATION.md §3.1, §3.3.

Parameters

  • branch_text: str — (no description)
  • model: Any — (no description)
  • api_key: str | None = None — (no description)
  • custom_instructions: str | None = None — (no description)

Returns

(summary, usage) — the text AND what producing it cost. This is a real LLM call made outside the agent loop, so nothing else can observe its tokens; if it does not report them, they go uncounted (see :mod:tau_agent_core.usage).

tool_group

tool_group(tree: ConversationTree, entry_id: str) -> frozenset[str]

tau_agent_core.tree_surgery.tool_group · since 0.9.7

entry_id together with the entries that cannot be separated from it.

An assistant message that declares tool calls and the toolResult entries answering them are one unit as far as any provider is concerned: a copy of one without the other is a prefix that gets rejected. Rather than let the reader build that selection and refuse it at the commit, the browser expands a mark to this set — selecting either end selects both, and the reader SEES the group light up on the rows.

The pairing is structural: an assistant's results are looked for among its DESCENDANTS, and a result's declaring assistant among its ANCESTORS, which is the only relation that means "answered this call on this line". The first match per tool_call_id in row order wins, so an assistant whose call was re-run on two branches pulls in one result, not a group spanning both.

Parameters

  • tree: ConversationTree — The tree entry_id belongs to.
  • entry_id: str — The entry the reader marked.

Returns

The ids to mark, always including entry_id. A message with no tool calls and no tool_call_id is its own group, so this is the identity for the ordinary case.

Raises

  • KeyError — No entry has that id.