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—pathresolves 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 asession_infoentry to live. Distinct from "never named", which isNone.
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 itsreturnsdeclares, 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 acursorindata. 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—textresolves 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 whensub.expand_commandsisTrue, 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
/treethat never becomes one. The old code answered this by owning the whole dispatch block; now it asks the same functionsubmit()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—pathresolves to nothing, the extension declares no schema, orvaluesdoes 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 resolvenameagainst).
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—nameis empty.RuntimeError— The bound log has noappend_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):
-
Admission / concurrency —
sub.multitask_strategyagainst the in-flight-turn lock. -
"reject"returnsaccepted=Falsewith a reason, never blocking and never raising. "enqueue"waits for the in-flight turn (if any) to finish, then runs — never "parks" the waysend_user_message(deliver_as= "nextTurn")does; it is guaranteed to run within THIS call."rollback"(decision 2): if a turn is genuinely in flight, signals its :attr:_abort_signal(a REQUEST — the turn still unwinds through its ownfinally, 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) viaappend_navigate— the same "move the cursor, the abandoned suffix falls off theparentIdwalk" mechanismappend_branch_summaryuses (minus the summary; see :meth:~tau_agent_core.session_log.SessionLog.append_navigateandctx.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.Lockis 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_tokenbumped 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."fork"(decision 2): does not touch :attr:_turn_lockat 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 returnsaccepted=Falsewith 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) — reusingctx.spawn_branch's entire mechanism (BranchView, tool scoping, failure containment,branch_eventforwarding) — andsubmit()returnsaccepted=Trueimmediately, before the branch's turn finishes; there is no caller left to await it the wayspawn_branch's caller does.-
"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.AgentLoopthis session builds receives assteer_queue=and drains immediately before each provider call. Returnsaccepted=Truewith 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:
streamingBehavioris consulted onlyif (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:abortand byrollback'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 throughctx.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_threadsafeas the fix.RuntimeError— the derived depth exceedsMAX_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 SAMEasyncio.Taskas 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.promptbefore 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_strategyis 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_commandsresolved a command that was registered when :meth:resolve_commandlooked but gone by the time :meth:run_extension_commandran (an extension unloaded across theawait). Refusing loudly beats falling through to the model with text the user meant as a command.NotImplementedError—sub.silentisTrue— renderer suppression lands in Block 3 (see step 5).store_history=Falseis 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 anysubmit).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(orasyncio.create_taskit) instead. This is Textual'scall_from_threadrule — "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 bracketagent_startis not: a followUp re-entry runs a secondloop.run()inside onesubmit()."submission_end"—(submission, side_usage), in afinally, so it arrives however the turn ended."branch_event"—(lane, label, event), one perAgentEventaspawn_branch/forksub-agent emits, tagged with the branch's lane."branch_end"—(lane, label, error), once per branch, in afinally, so it arrives however the branch ended (returned, contained failure, or cancelled byabort()). The branch's OWNagent_endis not that bracket:AgentLoop.runemits it after the while loop rather than from afinally, 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 thebranch_summaryentry 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 bysession_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 themessage_iddomain, which entries are candidates — one ofConversationTree.complete_message_id's scopes.Noneeverywhere 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 underattach, 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 anelideshould resume at once the branch is minted, orNonewhen no elide is wanted or when one would hide nothing. Set only fordrop_context=True.hidden: int— How many entries that elide would remove from the fold.0wheneverelide_fromisNone.
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.messagesisConversationTree(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 toparent_id) plus the branch's own work. Choosingparent_idIS choosing the sub-agent's inherited context. No new context plumbing exists. - Isolation is mutual, and it is structural rather than enforced.
context_forwalks 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 thoughentries()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— Whetherrpc.COMMAND_TABLEexposes it today.Falseis 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.Nonemeans its parameters cannot be written in this vocabulary and the hand-written wire schema is their only statement;submitis the case, carrying images and a correlation object that no :class:Domaindescribes.Noneis 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 vocabularyrpc.commands._assert_supported_schemaaccepts —type,properties,required. Every capability declares one and_check_registryrefuses aNone, 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.
header¶
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, orNonefor 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 tocursorinclusive;"descendants_of_cursor"is the subtree below it, excludingcursoritself.cursor: str | None = None— The entry the two scoped variants are relative to.Noneuses 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, whencursoris 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, orNonefor 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_idnames 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¶
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:Capabilitythat computes the legal values, when they depend on live state.field_kind: str = 'text'— Which of :data:~tau_agent_core.extension_types.FORM_FIELD_KINDSa 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 answerssession_idwith 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 tovaluefor 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—Trueiff a command by that name existed and ran (Falselets 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, orNone). 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 usesctxexplicitly).
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 scopedmessage_idargument is relative to, carried through from the :func:next_stepcall 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 ascopiedFrom.parent_source_id: str | None— The source id of this mint's parent, orNonewhen 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 forSessionLog.append_at, includingcopiedFrom.
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.Nonewhen a caller performed the capability directly.mutation: str— The capability that ran.data: dict[str, Any]— What it returned, keyed as itsreturnsdeclares. JSON-able.cursor: str | None = None— The session-log cursor after the call, orNonefor a session with no log. It is the promoted copy ofdata["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'sref.fork(source, cwd)— a new session carryingsource's history.list(cwd)— newest-first listing metadata,cwd=Nonefor 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 filesession_name: str | None = None— Human-readable session namecwd: str | None = None— Working directory for this sessionmodel: str | None = None— Model identifier used for this sessionmodel_name: str | None = None— Human-readable model namecreated_at: int = 0— Creation timestamp (ms since epoch)message_count: int = 0— Total number of message entriesstatus: str = 'idle'— Session status stringentries: 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 WROTEsummary. Not the conversation's model:AgentSession._summarizer()(agent_session.py:866) routes alocal_summarizerpolicy's compaction through a different one, so the two genuinely differ and the transcript could not previously say which (§8.1).summary_usage— what generatingsummarycost, 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_beforesays 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_tokensover them. Passed in rather than recomputed here — see :meth:append_elidefor why.agent_spec_id— the id of theagent_speccustomEntryin force over the covered span, from :func:agent_spec_in_force.Noneis a real answer (a pi-imported log, or a store driven without anAgentSession, 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_specre-runs at construction andset_modelbut not atload_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— Iffirst_kept_entry_idis 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 fromposition: 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¶
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 fileactive_entry_id: str | None = None— Current position in the tree (None = root)model: str | None = None— Model identifier used for this sessionmodel_name: str | None = None— Human-readable model namecwd: str | None = None— Working directory when session was createdsystem_prompt: str | None = None— System prompt usedsession_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_tokensover 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, orNoneif this session has never compacted.usage: dict[str, Any] | None— :meth:AgentSession.get_usage—Nonebefore 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.Noneeverywhere 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 nostaterides 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 sevendeclined_becauseentries 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
toolResultwhosetool_call_idno 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_reasonrejects, 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—rawis 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 toFalsewould silently turn/autocompact yesinto "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) — anavigateor 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:
- move the leaf to the plan's attach point (the last kept mark);
- mint each copy with
append_at, parented at the previous one; - move the leaf onto the last minted entry;
- 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_surgeryputs them into tree order.drop_context: bool— Whether the branch keeps only the selection.Trueappends an elide resuming at the root-most mark, so the context becomes the system prompt plus the branch.Falseleaves 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.AgentSessionto read models, extensions and the session tree from.runtime: Any = None— The :class:~tau_agent_core.agent_session_runtime.AgentSessionRuntimeto read the session catalog and the working directory from. Separate fromsessionbecause both live there, not on the session.pathneeds it as much assession_iddoes: 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— Formessage_id, which entries are candidates.cursor: str | None = None— For a scopedmessage_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.Noneand{}are the same thing.options: dict[str, list[str]] | None = None— The legal values for any argument whose domain renders as aselect, 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 aselectandoptionscarries 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¶
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, orNonefor 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.Noneand{}are the same thing: the flow's first step.cursor: str | None = None— The entry amessage_idargument's scope is relative to. Required of the caller rather than read off a session, for the reasonresolve_commandtakesextension_commandsas 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.Trueplans anelideresuming at the root-most mark, so the context becomes the system prompt plus the branch;Falseleaves 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 duplicatedtool_call_idstops 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 thebranch_summaryentry 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_idnames no entry (checked first, before the model call —subtree_textanswers""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 bysession_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 treeentry_idbelongs 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.