Skip to content

Extensions

BranchResult

class BranchResult(ok: bool, lane: str, label: str, leaf: str | None, messages: list[dict[str, Any]], error: str | None)

tau_agent_core.extension_types.BranchResult

What a C2/W14 branch sub-agent came back with (ctx.spawn_branch).

ok is the field callers must actually read. A sub-agent that failed returns a BranchResult with ok=False rather than raising, because failure containment is the design (§9.2/5) — one bad evaluator in a fan-out must not kill the primary turn. The cost of that choice is that an unchecked ok turns a failure into an empty-but-successful-looking answer, so the field is first and the docstrings say so.

leaf is the branch's final entry id — the handle the spawner's fold step uses to read the verdict back (or to ctx.summarize_branch(leaf) it) before making its one distilled append on the primary cursor.

Constructor parameters

  • ok: bool — (no description)
  • lane: str — (no description)
  • label: str — (no description)
  • leaf: str | None — (no description)
  • messages: list[dict[str, Any]] — (no description)
  • error: str | None — (no description)

ExtensionAPI

class ExtensionAPI(registry: ExtensionRegistry | None = None, event_bus: EventBus | None = None, context: ExtensionContext | None = None, session: Any = None, hook_handlers: ExtensionHandlers | None = None, config: dict[str, Any] | None = None)

tau_agent_core.extension_types.ExtensionAPI

Public API exposed to extension modules.

Reference: SUBPHASE-0.0.md, "8. Extension API Surface" section.

This is the ONLY API extension modules use. Extensions must not import τ-agent-core internals.

Constructor parameters

  • registry: ExtensionRegistry | None = None — ExtensionRegistry for tool/command/flag management.
  • event_bus: EventBus | None = None — EventBus for event subscription.
  • context: ExtensionContext | None = None — ExtensionContext with session state.
  • session: Any = None — AgentSession for messaging.
  • hook_handlers: ExtensionHandlers | None = None — This extension's OWN ExtensionHandlers bucket in the session ExtensionRunner (load order preserved). api.on() routes the four MUTATING hooks (tool_call / tool_result / before_agent_start / context) here — the dispatch surface the loop's hook call-sites gate on. Left None for an api that is not bound to a runner bucket; registering a hook on such an api then RAISES (Fail-Early) rather than silently no-op'ing (S24).
  • config: dict[str, Any] | None = None — This extension's OWN per-extension config slice (E6 §2 / S40). Sourced from ~/.tau/config.json "extensions": {"<name>": {…}} keyed by the extension's file stem, with per-run --ext-config <name>.<key>=<value> overrides applied on top. The session slices the right entry per extension in AgentSession._bind_extension_api and passes it here; None → an empty dict (an unconfigured extension reads {}, never a fabricated value — Fail-Early leaves defaulting to the extension).

append_entry

append_entry(custom_type: str, data: dict) -> None

tau_agent_core.extension_types.ExtensionAPI.append_entry

Persist durable, NON-message extension state onto the session tree (E6 §2 / S39).

Appends a {customType, data} node of its own tree entry KIND (customEntry) to the authoritative session log via AgentSession._append_custom_entry. This REPLACES the former RAM-only registry _entry_store, which was lost on restart (G4): the entry now persists, survives a reload, and is readable back through ctx.entries() (the reconstruction path S56's TreeStore builds on).

It is deliberately NOT a message: ConversationTree never folds a customEntry into the loop context and convert_to_llm never sees it, so this is tree-as-backplane state — on the durable path, excluded from model input. To inject a node the model reads, use send_message (visible_to_model) or send_user_message instead.

Parameters

  • custom_type: str — (no description)
  • data: dict — (no description)

Raises

  • RuntimeError — if no session with _append_custom_entry is bound (e.g. a bare ExtensionAPI()). Fail-Early: raise rather than silently drop the entry into a RAM store that evaporates on restart.
  • ValueError — propagated from _append_custom_entry when custom_type is empty or data is not a dict.

emit

emit(topic: str, payload: Any) -> None

tau_agent_core.extension_types.ExtensionAPI.emit

Publish payload on this extension's channel ext:<name>:<topic> (S52).

Inter-extension pub/sub — a faithful port of pi's pi.events.emit (event-bus.ts), adapted to τ's single notify :class:~tau_agent_core.events.EventBus. Fire-and-forget, in-RAM broadcast to every handler another (or the same) extension subscribed with api.on("ext:<name>:<topic>", handler). The channel is ALWAYS namespaced under this emitting extension's own name (:meth:_emitting_extension_name), so an extension can only publish under its own namespace and a subscriber gets unforgeable provenance — τ's discipline over pi's free-form channel strings.

This is deliberately NOT a backplane: it touches neither the session tree, the session log, nor convert_to_llm, so a custom-channel payload is NEVER model-visible (the tree is the only durable, model-visible channel — E5 §1). It is process-local and evaporates on restart; use :meth:append_entry / :meth:send_message for anything durable or model-facing.

Dispatch is synchronous per the EventBus contract (subscribed handlers run when this coroutine is awaited); a handler that raises is SURFACED through the bus on_error path (S44), never swallowed.

Parameters

  • topic: str — (no description)
  • payload: Any — (no description)

Raises

  • ValueError — if topic is not a non-empty string.
  • RuntimeError — if this api is not bound to a runner bucket (no extension identity to namespace under) — Fail-Early, via :meth:_emitting_extension_name.

get_all_tools

get_all_tools() -> list[Any]

tau_agent_core.extension_types.ExtensionAPI.get_all_tools

Get all registered tools.

Returns

List of tool info from the registry.

get_session_name

get_session_name() -> str | None

tau_agent_core.extension_types.ExtensionAPI.get_session_name

Read the session's current display name (pi getSessionName), or None if never set.

Thin delegator to module-level :func:read_session_name — see B5's note on :func:apply_session_name for why this is factored out.

on

on(event: str, handler: Callable) -> Callable[[], None]

tau_agent_core.extension_types.ExtensionAPI.on

Subscribe to an event — routed by KIND (S24 bridge).

The six MUTATING hooks (ExtensionRunner.HOOK_EVENTS: tool_call / tool_result / before_agent_start / input / turn_end / user_turn_end) AND the two notify-grade session-lifecycle hooks (ExtensionRunner.LIFECYCLE_EVENTS: session_start / session_shutdown, S41) are dispatched by the session's separate ExtensionRunner, whose call-sites gate on has_handlers(event). Those registrations must land in THIS extension's runner bucket (self._hook_handlers), not on the notify EventBus — otherwise they are a silent no-op in a real session (the bug S24 closes), and the lifecycle hooks in particular route through the runner precisely so their handler errors are SURFACED (S44) instead of swallowed like the bus. Every other (notify) event keeps going to the EventBus.

turn_end (S43) is the mutating variant: api.on("turn_end", …) now routes to the runner, where a handler may return {message} for a durable append or return nothing to observe. The notify-grade turn_end AgentEvent on the EventBus is UNCHANGED — pure observers still reach it via api.on("all", …) or :meth:AgentSession.subscribe.

user_turn_end is turn_end's once-per-prompt() sibling (§12.4 / §16.5). turn_end fires per AGENT-LOOP turn — six times for an utterance resolved in six tool round-trips — which is right for a per-completion observer and wrong for anything that should happen once per utterance. api.on("user_turn_end", …) fires exactly once, after the loop, the followUp drain and auto-compaction, with the same durable {message} append. Choose by cadence: per completion, or per utterance.

The retired context hook (E5 §3.2 / S30) is rejected UP FRONT: it was removed from HOOK_EVENTS, so left unguarded it would fall through to the notify EventBus and bind silently to a channel nothing ever emits — a dead no-op. Fail-Early: raise an unknown-hook error naming the durable replacement instead.

A custom inter-extension channel ext:<name>:<topic> (E7 §3 / S52) is neither a hook nor a retired name, so it takes the same EventBus fallthrough as a notify event: api.on("ext:pub:ping", handler) receives whatever the pub extension broadcasts via :meth:emit. These channels are in-RAM, fire-and-forget, and never model-visible — explicitly NOT a backplane.

Parameters

  • event: str — Event type (e.g., 'agent_start', 'tool_call', 'all').
  • handler: Callable — Callable that receives the event (an AgentEvent for notify events; a (event_dict, ctx) pair for hook events).

Returns

An unsubscribe function.

Raises

  • RuntimeError — registering the retired context hook (removed in E5 §3.2 / S30), or registering a mutating hook on an api that was never bound to a runner bucket (hook_handlers is None). Fail-Early — a hook with nowhere to dispatch is a construction bug, not a no-op.

register_command

register_command(name: str, command: dict) -> None

tau_agent_core.extension_types.ExtensionAPI.register_command

Register a slash command (forwards to the registry).

Parameters

  • name: str — (no description)
  • command: dict — (no description)

register_shortcut

register_shortcut(key: str, command: str, *, args: str = '', description: str | None = None) -> None

tau_agent_core.extension_types.ExtensionAPI.register_shortcut

Bind a key to a command in the guarded extension chord namespace (E10 §6 / S69).

Mirrors pi's registerShortcut(shortcut, options) (types.ts:1182), adapted to τ's dispatch model: instead of a raw handler callable, a shortcut names a command an extension registered via :meth:register_command, dispatched exactly like a panel action (S68) or a typed /name args. Keeping the binding a command name (not an opaque callable) means the SAME verb is reachable three ways — chord, palette, and /command — and stays runnable headless (a keyboard shortcut has no headless surface, but the command it fires does).

key is the chord TAIL — the second key pressed after the ctrl+e extension leader. This is the "guarded namespace": the TUI only ever binds extension shortcuts under that leader, never as bare global keys, so an extension physically cannot clobber a core binding (ctrl+c/ctrl+n/…). Registering key="g" for command="fleet_status" makes ctrl+e then g dispatch /fleet_status.

Parameters

  • key: str — The chord-tail key (e.g. "g", "1"). A non-empty token with no whitespace.
  • command: str — The name of the command to dispatch (a register_command name). Not required to exist yet at registration time — an unknown command surfaces at dispatch (handled=False), like a panel action.
  • args: str = '' — Argument string passed to the command's handler (default ""), the same slot a typed /name args fills.
  • description: str | None = None — Optional label for the chord menu / palette entry; falls back to the command's own registered description when omitted.

Raises

  • ValueError — key or command is not a non-empty string, or key contains whitespace (a chord tail is a single key token — Fail-Early rather than silently binding an unreachable key).
  • TypeError — args is not a string, or description is neither a string nor None.

register_tool

register_tool(definition: dict | ExtensionToolDefinition) -> None

tau_agent_core.extension_types.ExtensionAPI.register_tool

Register a tool callable by the LLM (pi ToolDefinition shape).

Mirrors pi's registerTool(tool: ToolDefinition) (coding-agent/src/core/extensions/types.ts:433). The definition is a plain dict — NOT a Pydantic/TypeBox model — carrying:

  • name (str): tool name used in LLM tool calls.
  • description (str): description sent to the LLM.
  • parameters (dict): JSON-schema dict for argument validation.
  • execute (callable): execute(tool_call_id, params, signal, on_update, ctx) returning an AgentToolResult-shaped value (may be sync or async). ctx is the bound ExtensionContext.

Optional keys: label (defaults to name for UI), prompt_snippet, prompt_guidelines, execution_mode ("sequential"/"parallel").

The dict is validated into an :class:~tau_agent_core.tools.base.ExtensionToolDefinition — the schema for this shape — with source="extension". Passing that model directly works too. Either way the registry stores the model, so every reader downstream sees typed attributes instead of a mapping whose keys it has to guess; the resolved tool is merged into the loop's tools next turn.

Parameters

  • definition: dict | ExtensionToolDefinition — (no description)

Raises

  • ValueError — if a required key is missing.
  • TypeError — if parameters is not a dict or execute is not callable.

send_message

send_message(message: dict, options: dict | None = None) -> None

tau_agent_core.extension_types.ExtensionAPI.send_message

Append a durable custom message node onto the active path (pi sendMessage).

Persists {customType, content, display?, details?} as a role: "custom" tree node via AgentSession._append_custom_message (E6 §2 / S38) — it renders in the transcript / tree and survives a reload, exactly like a before_agent_start injection.

Per D-E6-1 the node is display-only by default. Pass options={"visible_to_model": True} to also feed it to the model (remapped custom→user on the wire); otherwise it is excluded from convert_to_llm and never reaches the LLM. This is intentional: it does NOT create a third model-visible default channel — before_agent_start and send_user_message already serve that.

Parameters

  • message: dict — (no description)
  • options: dict | None = None — (no description)

Raises

  • RuntimeError — if no session with _append_custom_message is bound (e.g. a bare ExtensionAPI()). Fail-Early: raise rather than silently drop the message (the old inert no-op called a nonexistent method and did nothing).
  • ValueError — propagated from _append_custom_message when message lacks content or customType.

send_user_message

send_user_message(content: str, deliver_as: str = 'followUp') -> None

tau_agent_core.extension_types.ExtensionAPI.send_user_message

Queue a user message for the agent (pi sendUserMessage).

deliver_as selects the delivery mode. The parameter stays a plain str so future modes stay extensible (decision 5), but the three modes the queue supports are validated here:

  • "followUp" (default): drains at the end of the current prompt() and re-enters within the same call.
  • "nextTurn": queued for the next prompt().
  • "steer" (docs/SUBMISSION-LIFECYCLE.md phase 4): delivered by the loop running RIGHT NOW, before its next LLM call — pi's sendUserMessage(..., {deliverAs: "steer"}). This is how a hook steers the turn it is itself running inside; ctx.submit() cannot do it (a submission from the in-flight turn's own task is refused, because it could never be admitted).

Parameters

  • content: str — (no description)
  • deliver_as: str = 'followUp' — (no description)

Raises

  • ValueError — if deliver_as is not "followUp", "nextTurn" or "steer".
  • RuntimeError — if no session with a message queue is bound (e.g. a bare ExtensionAPI() with no session). Fail-Early: raise rather than silently drop the message (the old hasattr no-op).

set_active_tools

set_active_tools(names: list[str]) -> None

tau_agent_core.extension_types.ExtensionAPI.set_active_tools

Enable/disable tools by name (forwards to the registry).

Parameters

  • names: list[str] — (no description)

set_session_name

set_session_name(name: str) -> None

tau_agent_core.extension_types.ExtensionAPI.set_session_name

Set the session's durable display name (pi setSessionName, E9 / S64).

Thin delegator to module-level :func:apply_session_name — docs/ RPC-TIER-B.md B5 factors this body out to ONE definition shared with the RPC set_session_name verb, rather than each maintaining its own copy of the Fail-Early raise. See that function's docstring for the full behavior and the raise conditions.

Parameters

  • name: str — (no description)

submit

submit(text: str, *, multitask_strategy: MultitaskStrategy = 'reject', images: list[dict[str, Any]] | None = None, correlation: dict[str, Any] | None = None, allow_user_input: bool = False) -> SubmissionResult

tau_agent_core.extension_types.ExtensionAPI.submit

Originate an agent turn as THIS extension (docs/SUBMISSION-LIFECYCLE.md).

The extension half of "one door for every input source": an extension bound to an external event source (a bus subscription, a timer, a webhook) is the one deciding when a turn happens, and this is how it says so — the same :meth:AgentSession.submit admission point the TUI, headless, and the SDK funnel through, so concurrency policy is decided ONCE in the core instead of re-invented per extension (nats_bus's hand-rolled turn_in_flight flag is the workaround this deletes).

source="extension" and submitter=<this extension's own name> are supplied BY THIS BINDING from the caller's own runner bucket (:meth:_emitting_extension_name) and are deliberately not parameters — the same unforgeability :meth:emit has for ext:<name>:<topic> channels. An extension cannot claim to be a human, or to be another extension. That matters because phase 2 put source/submitter on every :class:~tau_agent_core.events.AgentEvent precisely so a renderer could tell a bus-driven turn from a typed one; a spoofable field would make the distinction worthless.

expand_commands is likewise not a parameter: it stays False (the :class:~tau_agent_core.submission.Submission default) so injected text can never smuggle a /compact through a bus payload — pi's sendUserMessage(expandPromptTemplates: false). An extension that wants to compact calls :meth:ExtensionContext.compact, the typed API, not a string.

depth is likewise not a parameter: :meth:AgentSession.submit derives it (decision 3). A self-continuing extension — one whose turn_end hook spawns a task that calls this method, whose turn fires turn_end again — climbs one per link because the spawned task inherits the turn's :data:~tau_agent_core.submission.DRIVING_SUBMISSION_DEPTH, and the eleventh link RAISES rather than looping forever. A submission delivered by a task that PREDATES the turn (a bus subscription loop, a timer) is not self-submission and stays at depth 0 however much traffic it delivers mid-turn.

Parameters

  • text: str — The utterance to run a turn on.
  • multitask_strategy: MultitaskStrategy = 'reject' — Policy against an in-flight turn — LangGraph's parameter, on the submission rather than the submitter. Defaults to "reject" (Fail-Early: a refusal you can see, not a queue you forgot about), which returns accepted=False with a rejection_reason rather than raising or dropping silently. See :meth:AgentSession.submit for every strategy's exact semantics.
  • images: list[dict[str, Any]] | None = None — Optional image dicts for a multimodal submission.
  • correlation: dict[str, Any] | None = None — Free-form origin detail — bus subject + binding id, cron id, HTTP request id — carried onto every event this turn emits so a renderer can fan out to the right consumer. Validated JSON-safe at :class:~tau_agent_core.submission.Submission construction (decision 4): a live object here raises HERE, naming the key, rather than detonating in a JSON renderer three hops downstream.
  • allow_user_input: bool = False — Jupyter allow_stdin — whether code running under THIS submission may prompt a human. Default False: a bus- or timer-driven turn has nobody at a keyboard. ENFORCED: for the whole of the admitted turn, :class:ExtensionUI's blocking dialogs (confirm/select/input/form) bypass the TUI delegate and take the headless-answer route, so with no --ui-defaults policy they raise :class:HeadlessDialogError instead of putting a modal in front of whoever happens to be at the terminal. Pass True only when a human really is expecting to be asked.

Returns

class:~tau_agent_core.submission.SubmissionResult — accepted plus either this turn's messages or a rejection_reason. A refusal is a RESULT (LSP's ApplyWorkspaceEditResult), not an exception.

Raises

  • RuntimeError — if this api is not bound to a runner bucket (no extension identity to submit under) — Fail-Early, via :meth:_emitting_extension_name.
  • RuntimeError — if this api is not bound to an AgentSession (nothing to submit to).
  • ValueError — if correlation carries a non-JSON value (decision 4).

submit_threadsafe

submit_threadsafe(text: str, *, multitask_strategy: MultitaskStrategy = 'reject', images: list[dict[str, Any]] | None = None, correlation: dict[str, Any] | None = None, allow_user_input: bool = False) -> concurrent.futures.Future[SubmissionResult]

tau_agent_core.extension_types.ExtensionAPI.submit_threadsafe

Originate a turn from a FOREIGN loop or thread (docs/SUBMISSION-LIFECYCLE.md).

The marshalling counterpart to :meth:submit, for the driver whose callback does not run on the session's loop: a paho-mqtt client thread, a watchdog filesystem observer, a WSGI/webhook request thread, a threading.Timer. Those contexts have no event loop to await on, so this is synchronous — it hands the submission to the session's own loop (:meth:AgentSession.submit_threadsafe) and returns a :class:concurrent.futures.Future for the result.

Which one to use is a question about the CALLBACK, not a matter of taste, and it is answerable: if the library delivers events by awaiting your coroutine on the loop the session runs on — as nats-py does, because its client was connected from a session_start handler running on that very loop — the callback is already home and :meth:submit is correct. If the library spawns its own thread, this method is the only correct call, and :meth:submit will say so by raising rather than corrupting a turn silently.

Every unforgeability property of :meth:submit holds here identically — source="extension" and submitter come from this api's own runner bucket, and expand_commands stays False. See :meth:submit for what each argument means; they are the same arguments.

Parameters

  • text: str — (no description)
  • multitask_strategy: MultitaskStrategy = 'reject' — (no description)
  • images: list[dict[str, Any]] | None = None — (no description)
  • correlation: dict[str, Any] | None = None — (no description)
  • allow_user_input: bool = False — (no description)

Returns

class:concurrent.futures.Future resolving to the class:~tau_agent_core.submission.SubmissionResult. Block on it with .result(timeout=…) if the driver thread wants the answer; drop it for fire-and-forget (a failure is still surfaced through the session's extension-error sink, never swallowed).

Raises

  • RuntimeError — if this api is bound to no runner bucket (no identity to submit under) or no AgentSession (nothing to submit to).
  • RuntimeError — if the session is not bound to a running loop yet, or if this is called from the session's own loop — see :meth:AgentSession.submit_threadsafe, which owns both checks.
  • ValueError — if correlation carries a non-JSON value (decision 4).

ui

tau_agent_core.extension_types.ExtensionAPI.ui: ExtensionUI

UI methods (TUI-only, no-ops in headless mode).

Returns

The ExtensionUI instance from the context.

ExtensionCapabilityError

tau_agent_core.sdk.ExtensionCapabilityError

A bus-touching extension's declaration is missing or cannot be honoured.

Raised at the factory, before register(api) runs (H8: "refuse rather than discover"). Two distinct causes share this type because both are the same failure — a declaration nobody validated — one at the writing end and one at the checking end:

  • the module sets TOUCHES_BUS = True but SUBJECTS is absent, empty, or not a sequence of non-empty strings (H7: "a silent omission is a load error rather than a hole in the diff");
  • the module declares TOUCHES_BUS = True and valid SUBJECTS, but the session it is loading into has no bus transport (bus_available=False) — a declared capability the session cannot back, refused rather than loaded and left to fail silently the first time a handler reaches for a bus it does not have.
  • the module declares CONFIG_SCHEMA and it is not a valid form spec. Same discipline: a schema nobody can render is a load error, not a settings screen that comes up empty.

ExtensionContext

class ExtensionContext(cwd: str = '.', session_manager: Any | None = None, signal: Any | None = None, is_idle: bool = True)

tau_agent_core.extension_types.ExtensionContext

Context passed to extension event handlers and tools.

Reference: SUBPHASE-0.0.md, "8. Extension API Surface" section.

Constructor parameters

  • cwd: str = '.' — Current working directory. Defaults to ".".
  • session_manager: Any | None = None — SessionManager instance. Defaults to None.
  • signal: Any | None = None — AbortSignal for this context. Defaults to None.
  • is_idle: bool = True — Whether the agent is idle. Defaults to True.

abort

abort() -> None

tau_agent_core.extension_types.ExtensionContext.abort

Abort the current operation by calling signal.abort() if available.

compact

compact(custom_instructions: str | None = None, defer: bool = False) -> Any

tau_agent_core.extension_types.ExtensionContext.compact

Compact the active conversation (delegates to AgentSession.compact).

Runs the full append-only compaction pipeline on the bound session's log (agent_session.py compact): build the active-path entries, summarize the compacted prefix via the LLM, and APPEND a compaction entry so the prefix drops out of future context at read time. Returns the CompactionResult (or None when there is nothing to compact).

Two variants (S20 / decision 3):

  • defer=False (default): the IMMEDIATE variant — compacts now and returns the CompactionResult.
  • defer=True: the TURN-END-DEFERRED variant — a tool calling this mid-turn cannot compact under the live agent loop, so the intent is RECORDED and applied exactly once at the tail of prompt() (the same site as auto-compaction). Returns None immediately; the tool then returns its own normal result.

Parameters

  • custom_instructions: str | None = None — Optional extra focus for the summary.
  • defer: bool = False — When True, record the intent for the end-of-prompt drain instead of compacting now.

complete

complete(messages: list[dict[str, Any]], *, model: Any = None, constraints: Any = None, api_key: str | None = None) -> Any

tau_agent_core.extension_types.ExtensionContext.complete

One LLM request/response — no agent loop, no tree writes (C1).

The primitive behind every "classify / extract / draft" story where the result matters, not the process. Deliberately stateless and session-free: it touches neither the entry log nor the cursor, so it is safe under asyncio.gather at any fan-out. Errors propagate (no retry policy hidden inside).

With constraints, this is the retrieval-review verdict primitive::

verdicts = await asyncio.gather(*[
    ctx.complete(
        [{"role": "user", "content": f"Include {doc}?"}],
        model="local-llm-small",
        constraints=DecodeConstraints(choices=["include", "exclude"]),
    )
    for doc in docs
])

Each verdict is constraint-verified by the provider, so a server that dropped the grammar raises rather than returning free prose as a verdict.

Parameters

  • messages: list[dict[str, Any]] — τ message dicts ({"role": ..., "content": ...}).
  • model: Any = None — model name (resolved via the config registry), a Model, or None for the session's current model.
  • constraints: Any = None — an optional tau_llm.DecodeConstraints.
  • api_key: str | None = None — overrides the session's key.

Returns

A tau_llm.AssistantMessage.

Raises

  • RuntimeError — the completion errored or was aborted.
  • ConstraintViolation — the constraint did not hold (see DecodeConstraints).

complete_text

complete_text(messages: list[dict[str, Any]], *, model: Any = None, constraints: Any = None, api_key: str | None = None) -> str

tau_agent_core.extension_types.ExtensionContext.complete_text

:meth:complete, returning the response's text (the common case).

Raises if the response carries no text — an empty answer is a failure, not an empty string to be silently threaded onward (Fail-Early).

The text is returned VERBATIM, not stripped. Under a constraint, whitespace is part of the constrained value: grammar.fixed("yes ") really does force the trailing space (verified live against llguidance), and the provider verified the output with it. Stripping here would hand the caller a string the constraint never produced — and one that fails the very membership check it just passed.

Parameters

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

emit_veto_record

emit_veto_record(*, extension: str | None, tool: str, reason: str) -> None

tau_agent_core.extension_types.ExtensionContext.emit_veto_record

Emit a blocked-tool VETO record via the shared UI's record sink (E7 §3 / S50).

Thin pass-through to :meth:ExtensionUI.emit_veto. The agent loop reaches this through the bound :class:ExtensionRunner (emit_veto_record) when a tool_call hook vetoes a call, so the JSON stream carries a kind: "veto" / blocked: true record (anchor G11) alongside the closed AgentEvent set. A no-op unless the headless --mode json path installed a sink.

Parameters

  • extension: str | None — (no description)
  • tool: str — (no description)
  • reason: str — (no description)

entries

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

tau_agent_core.extension_types.ExtensionContext.entries

The bound session log's raw, append-only entries (all kinds).

Thin pass-through to SessionLog.entries() — the same entry list a ConversationTree folds into context. Read-only: a copy per the log's contract, so mutating the returned list does not touch the log.

fork

fork(entry_id: str | None = None, mode: Literal['in_place', 'export'] = 'in_place', defer: bool = False) -> Any

tau_agent_core.extension_types.ExtensionContext.fork

Fork the conversation — one op, two modes (plan §7 decision E3-b).

  • mode="in_place" (default): branch WITHIN the one session log by APPENDing a navigate to entry_id (entry_id=None → pre-root), so the next turn appends a sibling branch off that point. Returns the re-rendered active-path messages (ConversationTree.context_for). This is the navigate+append in-place fork.
  • mode="export": copy the session into a NEW file via Session.fork (session_store.py:347; the source log is never touched), optionally positioning the new file's cursor at entry_id. Returns the new session file path as a string.

defer=True (S20 / decision 3): a tool calling this mid-turn cannot re-parent the log under the live agent loop, so the intent is RECORDED and applied exactly once at the tail of prompt(). Returns None immediately; the tool then returns its own normal result.

Fail-Early: export requires a concrete file-backed Session log — an in-memory SDK log cannot be exported to a file and RAISES rather than fabricating one.

Parameters

  • entry_id: str | None = None — (no description)
  • mode: Literal['in_place', 'export'] = 'in_place' — (no description)
  • defer: bool = False — (no description)

get_context_usage

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

tau_agent_core.extension_types.ExtensionContext.get_context_usage

Return context usage for the active model (pi ContextUsage shape).

Faithful port of pi's getContextUsage (agent-session.ts:2975 → ContextUsage at types.ts:281-287): returns {tokens, context_window, percent} where tokens is the estimated context-token count from estimate_context_tokens — the SAME estimate that drives auto-compaction (agent_session.py:523) — and percent is tokens / context_window * 100.

Returns None when the model has no positive context_window (pi returns undefined); that is a genuine "unknown", not a fabricated zero.

Raises

  • RuntimeError — if no session is bound — there is nothing to measure. Replaces the old fictional {"total_tokens": 0} stub (Fail-Early: raise rather than fabricate).

get_model

get_model() -> dict[str, Any]

tau_agent_core.extension_types.ExtensionContext.get_model

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

Delegates to :meth:AgentSession.get_model. Mirrors pi's ctx.model (types.ts:311) but as the three-field projection an extension needs, so it never has to reach the private ctx._session._model.

Raises

  • RuntimeError — if no session is bound (Fail-Early — no model to read).

get_usage

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

tau_agent_core.extension_types.ExtensionContext.get_usage

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

The public per-completion usage accessor (anchor G14): delegates to :meth:AgentSession.get_usage. Returns a copy of the last completion's usage dict, or None when no completion has landed yet. Read this from a message_end handler instead of pulling event.message["usage"].

Raises

  • RuntimeError — if no session is bound (Fail-Early — nothing to measure).
navigate(target_id: str | None, summarize: bool = False, custom_instructions: str | None = None) -> list[dict[str, Any]]

tau_agent_core.extension_types.ExtensionContext.navigate

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

Binds :func:tau_agent_core.tree_ops.navigate to the extension's own session. summarize=False APPENDs a navigate entry (zero LLM calls); the abandoned branch drops out of context via the parentId walk but stays on disk. summarize=True delegates to :meth:summarize_branch (append a branch_summary at the branch point). A target_id already at the cursor is a no-op.

Returns the re-rendered active-path messages (ConversationTree.context_for).

Parameters

  • target_id: str | None — (no description)
  • summarize: bool = False — (no description)
  • custom_instructions: str | None = None — (no description)

prompt

prompt(text: str) -> list[dict[str, Any]]

tau_agent_core.extension_types.ExtensionContext.prompt

DEPRECATED alias for :meth:ExtensionAPI.submit — use api.submit instead.

Run one agent turn on the bound session, returning this turn's messages. Kept working so existing extensions keep working; it is now exactly submit(text, multitask_strategy="enqueue") with the result's messages returned in place of the :class:SubmissionResult, which is the whole reason to prefer api.submit: a refusal is a typed result there, and unreachable through this signature.

What changed (docs/SUBMISSION-LIFECYCLE.md phase 5). This used to delegate to :meth:AgentSession.prompt, the interactive compatibility wrapper, so every turn an extension originated emitted lifecycle events stamped source="interactive", submitter="human" — a bus message indistinguishable from a person typing, which is precisely what phase 2's provenance fields exist to tell apart. It now builds its own :class:~tau_agent_core.submission.Submission with source="extension".

The submitter is :data:UNATTRIBUTED_EXTENSION, not the calling extension's name: a session has ONE shared :class:ExtensionContext (see that constant), so this object cannot know which extension called it. :meth:ExtensionAPI.submit is bucket-bound and reports the real name — that is the attributed door, and the reason this one is deprecated rather than merely renamed.

Concurrency is unchanged: "enqueue". A call from a DIFFERENT event source's own coroutine (a second bus message, a second timer tick) is genuine concurrency and waits for the in-flight turn, then runs — never a silent drop, never corrupted history. (api.submit defaults to "reject" instead, the Fail-Early default; ask for "enqueue" explicitly there if you want this behaviour.)

Re-entrancy — a hook (input/tool_call/turn_end/ user_turn_end) belonging to THIS SAME in-flight turn calling ctx.prompt() before its own turn has returned — is NOT the caller's problem to arbitrate: submit() detects it (same asyncio.Task as the turn already holding the admission lock) and RAISES immediately (review fix, must_fix #2). Before this, such a call deadlocked silently forever — every multitask_strategy either inspects or waits on a lock this task already holds, so nothing could ever release it. Decision 3's depth cap anticipates a bounded form of self-submission; nested execution that bypasses the lock to actually satisfy one is not implemented, so this raises unconditionally rather than hanging.

Parameters

  • text: str — (no description)

Raises

  • RuntimeError — if no session is bound (Fail-Early — nothing to prompt).
  • RuntimeError — this call is reentrant on the in-flight turn's own asyncio task (see above).

resolve_model

resolve_model(model: Any = None) -> Any

tau_agent_core.extension_types.ExtensionContext.resolve_model

Resolve model to a tau_llm.Model.

None → the session's current model. A string → looked up in the same config models registry the TUI picks from, via the resolver already injected on the session (AgentSession.set_model_resolver / backends.make_model_resolver). A Model → used as-is.

Model routing through the registry is the point: an extension's model choice is then configured inline with the agent's, in its existing config slice — e.g. "extensions": {"retrieval_review": {"model": "local-llm-small"}} — with no extension-private client plumbing.

Parameters

  • model: Any = None — (no description)

set_headless_ui_defaults

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

tau_agent_core.extension_types.ExtensionContext.set_headless_ui_defaults

Set the headless dialog-answer policy on the shared UI (E7 §3 / S48).

Delegates to :meth:ExtensionUI.set_headless_defaults; the frontends call this (via :meth:AgentSession.set_headless_ui_defaults) with the resolved --ui-defaults / config policy so a headless dialog auto-answers only when the user opted in. Validation (unknown method/token) raises ValueError — the caller surfaces it as a clean CLI error.

Parameters

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

set_model

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

tau_agent_core.extension_types.ExtensionContext.set_model

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

Delegates to :meth:AgentSession.set_model (pi setModel parity, adapted to τ's name-based resolver). Whatever the resolver raises for an unknown name propagates unchanged.

Parameters

  • name: str — The model name to resolve and switch to.

Returns

The new :meth:get_model projection.

Raises

  • RuntimeError — if no session is bound, or the session has no model resolver bound (both Fail-Early — no registry to resolve name).

set_record_sink

set_record_sink(sink: Callable[[dict[str, Any]], None] | None) -> None

tau_agent_core.extension_types.ExtensionContext.set_record_sink

Install the headless JSON record sink on the shared UI (E7 §3 / S49).

Delegates to :meth:ExtensionUI.set_record_sink; the headless --mode json path calls this (via :meth:AgentSession.set_extension_record_sink) so every loaded extension's api.ui.notify(...) becomes a structured record on the JSON stream instead of a stderr line (anchor G10).

Parameters

  • sink: Callable[[dict[str, Any]], None] | None — (no description)

set_ui_delegate

set_ui_delegate(delegate: Any) -> None

tau_agent_core.extension_types.ExtensionContext.set_ui_delegate

Set the TUI delegate for UI methods.

This enables TUI mode on the internal ExtensionUI, setting the delegate for all UI interactions.

Parameters

  • delegate: Any — TUI delegate object implementing confirm/select/input/notify.

shutdown

shutdown() -> None

tau_agent_core.extension_types.ExtensionContext.shutdown

Request a shutdown: marks shutdown_requested and, if a session_manager is bound, additionally calls its shutdown() too (the pre-existing pass-through — kept for whatever still relies on it). Idempotent; safe to call more than once.

shutdown_requested

tau_agent_core.extension_types.ExtensionContext.shutdown_requested: bool

True once shutdown() has been called on this context (P3).

spawn_branch

spawn_branch(parent_id: str | None, prompt: str, *, tools: list[str], model: Any = None, max_turns: int | None = None, label: str | None = None, system_prompt: str | None = None) -> 'BranchResult'

tau_agent_core.extension_types.ExtensionContext.spawn_branch

Run a tool-using sub-agent in its own lane of THIS conversation (C2/W14).

The sub-agent is a real AgentSession whose log is a :class:~tau_agent_core.session_log.BranchView — a second cursor over the same entry log. Its turns are recorded as a real in-tree branch (not an ephemeral side-session grafted back as a blob), so the session tree stays the single truth for everything the agent did, and on the JMFTS store the finished branch is already a searchable subtree.

parent_id chooses the inherited context: the fold walks up from it, so the sub-agent sees exactly the shared conversation prefix down to that point, plus its own work. Its writes can never reach the spawner's context nor move its cursor — structurally, not by a filter: they are never ancestors of the spawner's leaf, and a leaf→root walk cannot wander (docs/LANE-REMOVAL.md §3.1). Nothing on disk marks them as a sub-agent's, because a sub-agent's branch and a user's fork are the same shape and are meant to read the same (§1).

tools is a required hard allowlist, deliberately not defaulted. Sub-agents share the process and cwd, so "inherit the parent's tools" would silently hand a retrieval evaluator write and bash; and defaulting to [] would just as silently produce a sub-agent that cannot do the job it was spawned for. Naming the tools is the only option that cannot fail quietly. Pass [] to mean none.

Every name is checked against what the spawning session actually offers the model — session._build_turn_tools(), so the constructor's tools AND the extensions' registrations, resolved exactly as a turn resolves them. A name that is not in that list raises. Consequently no_tools="all" yields an empty list here, so a non-empty allowlist on such a session raises rather than routing tools around the suppression.

system_prompt defaults to None, which inherits the spawning session's own prompt (session._system_prompt) — today's behaviour, unchanged for every existing caller. Passing a string forks with a different spec instead: the one concrete blocker on "fork at a node with a different spec" (NODE-ADDRESSABLE-AGENTS.md §5 recipe 2, W1) was that this call hardcoded the parent's prompt with no override.

Failure is contained, not propagated (§9.2/5): a sub-agent that errors marks its own branch and returns ok=False; it never aborts the primary loop. A raise here would mean one bad evaluator in a fan-out kills the whole turn.

The branch's events are bracketed: each one is forwarded onto the primary bus's branch_event channel, and a single terminal branch_end (carrying lane, label and the error that ended it, or None) is emitted from a finally — so a consumer that opened something on the first event can close it whether the branch finished, failed, or was cancelled.

The sub-agent starts with no extensions (NODE-ADDRESSABLE-AGENTS.md Decision 4 / W4), by choice rather than oversight: the constructor below passes no extensions=, so a forked session never re-registers the parent's hooks. This is deliberate, not a gap to file — inheriting them would make the hook runner re-entrant across two concurrent turns (the parent's turn still running, the branch's turn also running, both walking the same registered hook state), which is a materially larger change than this method's scope. A caller that wants the sub-agent to carry extensions loads them onto sub itself before prompt().

Parameters

  • parent_id: str | None — (no description)
  • prompt: str — (no description)
  • tools: list[str] — (no description)
  • model: Any = None — (no description)
  • max_turns: int | None = None — (no description)
  • label: str | None = None — (no description)
  • system_prompt: str | None = None — (no description)

Returns

class:BranchResult. Check ok — a failed branch returns a result, it does not raise.

summarize_branch

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

tau_agent_core.extension_types.ExtensionContext.summarize_branch

Summarize the subtree at from_entry and splice it onto the active path.

Binds :func:tau_agent_core.tree_ops.summarize_and_navigate to the extension's own session: the capability extracts the branch text, summarizes it (raise-based on a failed or empty summary, Fail-Early) and APPENDs a branch_summary entry parented at from_entry, and this supplies the session's model and key and banks the tokens the summarizer spent. The abandoned children drop out of context via the parentId walk.

Returns the re-rendered active-path messages (ConversationTree.context_for).

Parameters

  • from_entry: str — (no description)
  • custom_instructions: str | None = None — (no description)

ui

tau_agent_core.extension_types.ExtensionContext.ui: ExtensionUI

UI methods (TUI-only, no-ops/headless-policy elsewhere) — E9 / S60.

The SAME shared ExtensionUI instance :attr:ExtensionAPI.ui exposes (both read self._ui off this one ExtensionContext), so a hook handler's ctx.ui.notify(...) / await ctx.ui.confirm(...) paints on the identical delegate an extension's top-level api.ui would. Every mutating-hook handler and every register_command handler is called as handler(event_or_args, ctx) with THIS ExtensionContext (never the ExtensionAPI), so without this property a hook-scoped ctx.ui call (pi's own idiom — permission-gate.ts, protected-paths.ts, claude-rules.ts all call ctx.ui.* from inside a pi.on(...)/command handler) had no surface to reach the delegate through; run_extension_command's own docstring already promised "the same ctx.ui every hook reaches" — this property makes that true rather than aspirational.

ExtensionInfo

class ExtensionInfo(name: str, path: str, tools: list[str], commands: list[str], shortcuts: list[str], hooks: list[str], content_hash: str = '', subjects: tuple[str, ...] = ())

tau_agent_core.sdk.ExtensionInfo

Read-only summary of one loaded extension for the /extensions surface.

Reference: EXTENSIONS-E5-WIRING.md §5 (E5.4 / S34). Carries an extension's display name, source path, and the tools / commands / shortcuts / hooks it registered — everything the palette listing shows for a loaded extension (shortcuts E10 §6 / S69).

content_hash and subjects are H7's addition (SIM_SPEC_v2 §16.6): the file's identity at load time and its declared bus subjects (() for an extension that does not touch the bus). This is the pair :func:~tau_agent_core.run_manifest.build_run_manifest emits into manifest.json beside harness and compaction.

Constructor parameters

  • name: str — (no description)
  • path: str — (no description)
  • tools: list[str] — (no description)
  • commands: list[str] — (no description)
  • shortcuts: list[str] — (no description)
  • hooks: list[str] — (no description)
  • content_hash: str = '' — (no description)
  • subjects: tuple[str, ...] = () — (no description)

ExtensionLoadError

class ExtensionLoadError(path: str, error: str)

tau_agent_core.sdk.ExtensionLoadError

A discovered extension that failed to load (pi types.ts:1590 errors[]).

Constructor parameters

  • path: str — (no description)
  • error: str — (no description)

ExtensionUI

class ExtensionUI(mode: Literal['tui', 'headless'] = 'headless', headless_policy: dict[str, str] | None = None)

tau_agent_core.extension_types.ExtensionUI

User interaction methods (TUI delegate, or a headless policy).

Reference: SUBPHASE-0.0.md, "8. Extension API Surface"; E7 §3 / S48.

In TUI mode the blocking dialogs (confirm/select/input) delegate to a TUI delegate that asks a real human. In headless mode there is no human, so each blocking dialog obeys the headless-answer POLICY set via :meth:set_headless_defaults (from --ui-defaults / config.json):

  • a method WITH a policy entry returns the explicitly-configured answer (confirm → True/False; select → first item; input → default);
  • a method WITHOUT one RAISES :class:HeadlessDialogError (S48 / D-E6-2).

The pre-S48 behaviour auto-answered every headless dialog (confirm→True, select→first, input→default) with no way to opt out — a silent auto-approve of whatever the dialog was gating. Raising by default makes the auto-answer an EXPLICIT choice instead of a hidden fallback.

TUI mode is not enough on its own (docs/SUBMISSION-LIFECYCLE.md, Submission.allow_user_input — Jupyter's allow_stdin). A blocking dialog reaches the delegate only if the submission driving the calling code permits it: :func:~tau_agent_core.submission.user_input_permitted is False for the whole of a turn admitted with allow_user_input=False, and each blocking dialog then takes the headless-answer route above even though a delegate and a live human exist. That is what makes the capability per-SUBMISSION rather than per-process: one embedded τ can serve an interactive session and a cron-triggered submission at the same time, and only the latter is barred from opening dialogs. Outside any submission-driven turn (a slash-command handler, session_start, continue_conversation()) nothing is published and behaviour is exactly as before.

notify is non-blocking (no answer to fabricate): it prints to stderr headless and paints on the delegate in TUI mode — unchanged, and NOT gated by allow_user_input, which is about asking a human, not telling one.

Constructor parameters

  • mode: Literal['tui', 'headless'] = 'headless' — Either 'tui' or 'headless'. Defaults to 'headless'.
  • headless_policy: dict[str, str] | None = None — Optional {method: token} headless-answer map (validated via :meth:set_headless_defaults). Defaults to no policy → headless dialogs raise (S48).

confirm

confirm(title: str, message: str) -> bool

tau_agent_core.extension_types.ExtensionUI.confirm

Show a confirmation dialog. Returns user's choice.

Delegates to the TUI delegate when a human is reachable (:meth:_human_delegate — TUI mode AND the driving submission's allow_user_input). Otherwise returns the policy answer (confirm=yes/true → True, confirm=no/false → False) or raises :class:HeadlessDialogError when no policy is set.

Parameters

  • title: str — (no description)
  • message: str — (no description)

emit_constraints

emit_constraints(summary: dict[str, Any], *, source: str | None = None) -> None

tau_agent_core.extension_types.ExtensionUI.emit_constraints

Echo the decode constraint that shaped a ctx.complete() call (G4/C).

Routes ONLY to the record sink — the --mode json record family (S49) — emitting {"type": "extension", "kind": "constraints", "extension": <path|null>, "constraints": <summary>} where summary is :meth:DecodeConstraints.describe's output ({"kind": "choices"|"json_schema" |"grammar", ...}). This retires the "describe() has zero non-test callers" debt: the ONE place a real constraint exists at completion time is ctx.complete(), so that is the honest producer of this record.

Guard: NEVER echo {"kind": "none"} — the caller only reaches here when constraints.has_constraint() is true, and this second check makes the "no fabricated placeholder" invariant local (Fail-Early: a none summary is dropped rather than emitted as a meaningless record). Like :meth:emit_veto this deliberately does NOT touch the TUI delegate or stderr, and with no sink installed it is a no-op (the JSON record family only exists on that one path).

Parameters

  • summary: dict[str, Any] — (no description)
  • source: str | None = None — (no description)

emit_veto

emit_veto(*, extension: str | None, tool: str, reason: str) -> None

tau_agent_core.extension_types.ExtensionUI.emit_veto

Emit a blocked-tool VETO record on the headless JSON stream (E7 §3 / S50).

Routes ONLY to the record sink — the --mode json record family (S49) — emitting {"type": "extension", "kind": "veto", "extension": <path|null>, "tool": <name>, "reason": <reason>, "blocked": true} so a parent orchestrating a child tau -p --mode json can tell a tool_call veto (anchor G11) from a generic errored tool result. Deliberately does NOT touch the TUI delegate or stderr: in the TUI the veto is rendered off the tool_execution_end AgentEvent's blocked field, and in --mode text it already surfaces as the persisted errored tool-result node — a stderr line here would be a duplicate. With no sink installed this is a no-op (not a fabricated channel — the JSON record family only exists on that one path).

Parameters

  • extension: str | None — (no description)
  • tool: str — (no description)
  • reason: str — (no description)

form

form(spec: dict[str, Any]) -> dict[str, Any] | None

tau_agent_core.extension_types.ExtensionUI.form

Show a DECLARATIVE form and return {field_name: value} (E10 §6 / S66).

The τ answer to pi's question/questionnaire — but a plain-data SPEC, not a widget factory (D-E6-4): spec = {title?, fields: [{name, kind, label?, default?, options?}, ...]} with kind one of :data:FORM_FIELD_KINDS (text/select/multiselect/confirm/ number). The spec is validated by :func:validate_form_spec up front so a malformed form fails BEFORE any UI is shown, in every mode.

Routing (mirrors the other blocking dialogs, S48):

  • TUI mode with a delegate, and a driving submission that permits asking a human (:meth:_human_delegate) → delegates to the frontend's single generic ExtensionFormScreen; a real human fills it. Returns the {name: value} dict on submit, or None on cancel/Esc (a cancelled form is NOT a fabricated set of answers — Fail-Early, same as :meth:select). A form is a blocking dialog like any other, so allow_user_input=False routes it down the policy path below rather than putting a screen in front of a human who did not originate the turn.
  • headless --mode json (a record sink is installed) → first emits one {"type": "extension", "kind": "form", …} record describing the request (visibility on the stream, like :meth:notify), THEN resolves via policy.
  • headless policy → with --ui-defaults form=defaults returns each field's declared default (:func:form_headless_value); with NO form policy RAISES :class:HeadlessDialogError. It NEVER silently auto-fills a form the user did not fill.

Parameters

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

Returns

dict[str, Any] mapping each field name to its answer, or None when a TUI user cancels. The headless defaults answer is always a dict (the user opted in — there is nothing to cancel).

input

input(title: str, default: str = '') -> str

tau_agent_core.extension_types.ExtensionUI.input

Show an input dialog. Returns user input or default.

Delegates to the TUI delegate when a human is reachable (:meth:_human_delegate). Otherwise input=default returns the default value; no policy raises :class:HeadlessDialogError.

Parameters

  • title: str — (no description)
  • default: str = '' — (no description)

interactive

tau_agent_core.extension_types.ExtensionUI.interactive: bool

Whether a human is watching a live surface right now.

True only in TUI mode with a bound delegate — the one case that can paint something (set_status/panel/notify) without producing a stderr line or a JSON record instead. Extension code with a high-frequency ambient update (an ASR partial, a tick) checks this BEFORE formatting or calling set_status/panel, so a headless run — including --mode json, whose record schema has no room for arbitrary per-partial noise — pays nothing for updates nobody can see.

Unlike :meth:_human_delegate this does not consult user_input_permitted(): that gate is permission to ask a human a blocking question mid-submission, not whether a screen exists to paint ambient state on.

notify

notify(message: str, level: str = 'info', *, source: str | None = None) -> None

tau_agent_core.extension_types.ExtensionUI.notify

Show a notification.

Routing (first match wins):

  • TUI mode with a delegate → paints on the delegate (the Textual toast).
  • headless --mode json (a :meth:set_record_sink is installed) → emits one {"type": "extension", "kind": "notify", …} record through the sink instead of stderr, so extension activity is visible in the JSON event stream (S49 — anchor G10).
  • otherwise (headless --mode text / SDK) → prints to stderr, unchanged.

source is the originating extension's identity when the caller knows it (the S44 error-surface path passes the failing extension's path). A plain api.ui.notify(...) cannot supply one: every bound extension shares the session's ONE :class:ExtensionUI (api.ui is that single instance — a test-enforced invariant), so the shared sink has no per-call attribution. Fail-Early: the record then carries "extension": null — the honest "unattributed" value — rather than a fabricated name.

Parameters

  • message: str — (no description)
  • level: str = 'info' — (no description)
  • source: str | None = None — (no description)

panel

panel(key: str, spec: dict[str, Any] | None, *, source: str | None = None) -> None

tau_agent_core.extension_types.ExtensionUI.panel

Show, update, or clear a persistent keyed PANEL (E10 §6 / S68).

The fleet-dashboard primitive (D-E6-4: a plain-data SPEC, not a widget factory). key names a persistent panel surface; re-calling the same key UPDATES that panel in place (a live delegate table ticking as children start / finish / cost), and spec=None CLEARS it (the fleet is done). spec is {title?, <body>, actions?} where <body> is EXACTLY ONE of table / list / text and actions is a list of {label, command, args?} — pressing an action DISPATCHES command back into the extension as a register_command call (the panel→extension loop). The spec is validated by :func:validate_panel_spec up front so a malformed panel fails BEFORE any UI is shown, in every mode.

Like :meth:set_status this is NON-BLOCKING display (a panel is not a dialog awaiting an answer), so it needs no headless answer policy — it routes exactly like :meth:notify:

  • TUI mode with a delegate → paints on the delegate's panel host, which mounts / updates / removes the keyed :class:ExtensionPanel.
  • headless --mode json (a :meth:set_record_sink is installed) → emits one {"type": "extension", "kind": "panel", "key": …, "spec": …} record through the sink (spec is the normalized dict, or null on clear) so a parent reading a child tau -p --mode json stream sees the panel and its declared actions (anchor G10). This IS the non-interactive headless policy (§6.3): the surface is visible on the stream, its actions simply cannot be pressed without a TUI — a panel is never TUI-ONLY.
  • otherwise (headless --mode text / SDK) → prints to stderr, unchanged from :meth:notify's fallback (honest, never a silent no-op).

source is the originating extension's identity when the caller knows it; a plain api.ui.panel(...) cannot supply one (every bound extension shares the session's ONE :class:ExtensionUI), so the record then carries "extension": null rather than a fabricated name — same contract as :meth:notify/:meth:set_status.

Parameters

  • key: str — (no description)
  • spec: dict[str, Any] | None — (no description)
  • source: str | None = None — (no description)

Raises

  • ValueError — if key is not a non-empty string (Fail-Early: a panel with no key has nothing to update or clear); or (via :func:validate_panel_spec) if spec is malformed.

select

select(title: str, items: list[str]) -> str | None

tau_agent_core.extension_types.ExtensionUI.select

Show a selection dialog. Returns selected item or None.

Delegates to the TUI delegate when a human is reachable (:meth:_human_delegate). Otherwise select=first returns the first item (or None if empty); no policy raises :class:HeadlessDialogError.

Parameters

  • title: str — (no description)
  • items: list[str] — (no description)

set_headless_defaults

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

tau_agent_core.extension_types.ExtensionUI.set_headless_defaults

Set (replace) the headless-answer policy, validating every entry (S48).

policy maps a dialog method to its answer token; keys must be in :data:HEADLESS_DIALOG_ANSWERS and each token must be one of that method's allowed answers (case-insensitive). Fail-Early: an unknown method or token RAISES :class:ValueError rather than being silently ignored, so a typo in --ui-defaults / config surfaces instead of leaving a dialog unexpectedly raising at runtime.

Parameters

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

set_record_sink

set_record_sink(sink: Callable[[dict[str, Any]], None] | None) -> None

tau_agent_core.extension_types.ExtensionUI.set_record_sink

Install (or clear) the headless JSON record sink (E7 §3 / S49 — G10).

The frontends call this (via :meth:AgentSession.set_extension_record_sink) on the --mode json headless path with a writer that serializes each record to one stdout line — the parallel record family alongside the closed AgentEvent set (like the session header line). Passing None restores the plain stderr sink. Nothing calls this in the TUI or in --mode text, so those paths keep the delegate / stderr behaviour.

Parameters

  • sink: Callable[[dict[str, Any]], None] | None — (no description)

set_status

set_status(key: str, text: str | None, *, source: str | None = None) -> None

tau_agent_core.extension_types.ExtensionUI.set_status

Set (or clear) a keyed slot in the extension status strip (E10 §6 / S67).

Ports pi's ctx.ui.setStatus(key, text) (types.ts:141): ambient, live state painted in a one-line footer strip. key identifies a SLOT — re-calling the same key UPDATES that slot in place (e.g. budget proximity ticking each turn), never appending a new one. text=None CLEARS the slot (pi's "pass undefined to clear"). Unlike :meth:confirm/:meth:form this is non-blocking display, so it needs no headless answer policy — it routes exactly like :meth:notify:

  • TUI mode with a delegate → paints on the delegate's status strip.
  • headless --mode json (a :meth:set_record_sink is installed) → emits one {"type": "extension", "kind": "status", …} record through the sink so a parent reading a child tau -p --mode json stream sees the ambient state change (S49 — anchor G10). A cleared slot rides the same record with "text": null.
  • otherwise (headless --mode text / SDK) → prints to stderr, unchanged from :meth:notify's fallback (honest, never a silent no-op).

source is the originating extension's identity when the caller knows it; a plain api.ui.set_status(...) cannot supply one (every bound extension shares the session's ONE :class:ExtensionUI), so the record then carries "extension": null rather than a fabricated name — same contract as :meth:notify.

Parameters

  • key: str — (no description)
  • text: str | None — (no description)
  • source: str | None = None — (no description)

Raises

  • ValueError — if key is not a non-empty string (Fail-Early: a status slot with no key has nothing to update or clear).

HeadlessDialogError

tau_agent_core.extension_types.HeadlessDialogError

A UI dialog was opened with no human reachable and no explicit --ui-defaults policy.

Raised by :meth:ExtensionUI.confirm / :meth:ExtensionUI.select / :meth:ExtensionUI.input / :meth:ExtensionUI.form when the corresponding method has no headless-answer policy (E7 §3 / S48) and no human can be asked. "No human can be asked" has TWO causes, and this one exception covers both because the consequence is identical:

  • headless mode — there is no TUI delegate at all;
  • allow_user_input=False — a delegate may well exist, but the submission driving this code declared that code running under it may not prompt a human (Jupyter's allow_stdin; docs/SUBMISSION-LIFECYCLE.md "The dataclasses", which names this class as the enforcement: "Enforcement stays HeadlessDialogError"). A cron- or bus-originated turn in a TUI process is exactly this case.

Either way, silently auto-answering would fabricate consent for whatever the dialog was gating — so Fail-Early: raise, naming the opt-in that restores an explicit auto-answer.

LoadExtensionsResult

class LoadExtensionsResult(extensions: list[LoadedExtension] = list(), errors: list[ExtensionLoadError] = list())

tau_agent_core.sdk.LoadExtensionsResult

Result of loading extensions — port of pi LoadExtensionsResult.

Reference: pi agent/../types.ts:1590. The runtime field is intentionally omitted until the API is bound to the live session (E1/S3).

Constructor parameters

  • extensions: list[LoadedExtension] = list() — (no description)
  • errors: list[ExtensionLoadError] = list() — (no description)

LoadedExtension

class LoadedExtension(path: str, register: Callable[..., Any], api: ExtensionAPI, content_hash: str = '', subjects: tuple[str, ...] = (), touches_bus: bool = False, config_schema: dict[str, Any] | None = None)

tau_agent_core.sdk.LoadedExtension

A successfully loaded extension.

Narrowed port of pi's Extension record (coding-agent types.ts:1577) to what S1 needs: the source path, the module-level register factory that was invoked, and the ExtensionAPI it registered against.

config_schema is the extension's own CONFIG_SCHEMA module attribute, normalized by :func:~tau_agent_core.extension_types.validate_form_spec at load — the declaration that lets a head render a settings screen for keys only the extension knows. None means the module declared none, which is every extension written before the attribute existed.

content_hash, subjects and touches_bus are H7/H8's addition (SIM_SPEC_v2 §16.6/§16.10): the file's identity at load time, and its declared bus subjects, if any. content_hash is a sha256 of the exact bytes compiled — the same source read used to exec the module — so two loads of the same path at different contents produce different hashes and are never mistaken for one condition (the pattern §15.1's producer, Trace.arm, and H5's compaction policy already established for this program: a configuration that changes what a number means is a mandatory partition key).

Constructor parameters

  • path: str — (no description)
  • register: Callable[..., Any] — (no description)
  • api: ExtensionAPI — (no description)
  • content_hash: str = '' — (no description)
  • subjects: tuple[str, ...] = () — (no description)
  • touches_bus: bool = False — (no description)
  • config_schema: dict[str, Any] | None = None — (no description)

apply_session_name

apply_session_name(session: Any, name: str) -> None

tau_agent_core.extension_types.apply_session_name

Persist name as session's durable display name via append_session_info — the SAME entry kind the file-backed tau_coding_agent.session_store.Session already exposes through its .name property (and display_title()'s "name, else first user message" fallback), so a name set here shows up in the session selector / TUI title exactly like a manually-renamed session file. ConversationTree never folds a session_info entry into context (the same non-message treatment as model_change/thinking_change), so this is ambient, reload-invariant metadata: persisted, but never model input.

The prior implementation looked for a _session_name attribute that AgentSession never defines — a silent no-op on every real session (only a MagicMock's auto-vivified attributes made the old tests pass). This corrects it to actually persist (Fail-Early: raise instead of silently doing nothing).

Parameters

  • session: Any — (no description)
  • name: str — (no description)

Raises

  • RuntimeError — no session is bound, or the bound session's log has no append_session_info (e.g. the SDK's RAM-only InMemorySessionLog — session naming needs a file-backed log).
  • ValueError — name is empty.

ext_channel

ext_channel(name: str, topic: str) -> str

tau_agent_core.extension_types.ext_channel

The namespaced EventBus channel for an extension pub/sub topic (E7 §3 / S52).

Returns ext:<name>:<topic>. The ext: prefix keeps custom inter-extension channels disjoint from the closed AgentEvent type set the notify bus also carries, and <name> (the emitting extension's file stem — the same stem that keys api.config) makes the channel's origin unforgeable: :meth:ExtensionAPI.emit derives name from the caller's own bucket, so an extension can only publish under its own namespace. A subscriber passes the full result string to api.on(...) to receive it.

Parameters

  • name: str — (no description)
  • topic: str — (no description)

form_headless_value

form_headless_value(field: dict[str, Any]) -> Any

tau_agent_core.extension_types.form_headless_value

The form=defaults headless answer for one validated field (S66).

Returns the field's declared default when present (the extension author's explicit value, trusted like input's default); otherwise the kind's natural empty value (select → its first option, which is always concrete). This is only reached once the user opts in via --ui-defaults form=defaults — with no policy the form raises instead of fabricating an answer.

Parameters

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

read_session_name

read_session_name(session: Any) -> str | None

tau_agent_core.extension_types.read_session_name

Read session's current durable display name, or None if never set.

Reads the SAME .name property the file-backed Session already derives from its latest session_info entry, so a fresh call always reflects the persisted log rather than a cached value — correct across a reload.

Parameters

  • session: Any — (no description)

Raises

  • RuntimeError — no session is bound, or the bound session's log has no name (e.g. an in-memory SDK session).

summarize_extensions

summarize_extensions(result: LoadExtensionsResult) -> list[ExtensionInfo]

tau_agent_core.sdk.summarize_extensions

Per-extension name/path/tools/commands/hooks from a LoadExtensionsResult.

Reference: EXTENSIONS-E5-WIRING.md §5 (E5.4 / S34). The palette (/extensions) reads this to list each loaded extension; result.errors is surfaced alongside by the caller (load failures).

Each loaded extension's api is bound to its own runner bucket (:class:~tau_agent_core.extensions.runner.ExtensionHandlers, labelled by the extension's file path — see AgentSession._bind_extension_api / _standalone_api_factory), which is the ONLY place that records which extension registered which tool/command/hook. A loaded extension whose api has no bucket is a construction bug, so this raises rather than fabricating an empty listing (Fail-Early).

Parameters

  • result: LoadExtensionsResult — (no description)

validate_form_spec

validate_form_spec(spec: Any) -> tuple[str, list[dict[str, Any]]]

tau_agent_core.extension_types.validate_form_spec

Validate + normalize a ui.form spec into (title, fields) (S66).

The single source of truth for the declarative form contract, shared by :meth:ExtensionUI.form (headless path + early-fail) and the TUI's ExtensionFormScreen (which re-validates the same raw spec), so the two can never disagree about what a field means.

spec is a plain dict {title?: str, fields: [field, ...]}; each field is {name: str, kind: str, label?: str, default?: Any, options?: [str, ...]}. A select/multiselect field MUST carry a non-empty options list of strings. Returns the resolved title (defaults to "Form") and the normalized field list (label defaulted to name; default/options preserved when present).

Fail-Early: a non-dict spec, an empty/absent fields list, a field missing a non-empty string name, a duplicate name, an unknown kind, or a select/multiselect without a valid options list RAISES :class:ValueError rather than silently dropping the field.

Parameters

  • spec: Any — (no description)

validate_form_values

validate_form_values(fields: list[dict[str, Any]], values: dict[str, Any]) -> None

tau_agent_core.extension_types.validate_form_values

Check answered values against the fields :func:validate_form_spec returned.

The reverse direction of the form contract: validate_form_spec says what may be asked, this says whether an answer is admissible. Used where a form's answers become durable state — an extension's config slice — rather than a one-shot return value.

Parameters

  • fields: list[dict[str, Any]] — The normalized field list, as returned by :func:validate_form_spec.
  • values: dict[str, Any] — The answers, keyed by field name. Every declared field must be present; missing is not the same as empty and is not filled in here.

Raises

  • ValueError — an undeclared key, a missing declared field, a value whose type does not match its kind, or a select/multiselect value outside its declared options. Fail-Early: nothing is coerced and nothing is dropped.

validate_panel_spec

validate_panel_spec(spec: Any) -> dict[str, Any]

tau_agent_core.extension_types.validate_panel_spec

Validate + normalize a ui.panel spec into {title, body, actions} (S68).

The single source of truth for the declarative panel contract, shared by :meth:ExtensionUI.panel (headless record + early-fail) and the TUI's ExtensionPanel widget (which re-validates the same raw spec), so the two can never disagree about a panel's shape.

spec is a plain dict {title?: str, <body>, actions?: [...]} where <body> is EXACTLY ONE of table / list / text (see :func:_validate_panel_body) and actions is the optional command-dispatch list (see :func:_validate_panel_actions). Returns the normalized {"title", "body", "actions"} dict (title defaults to "Panel").

Fail-Early: a non-dict spec, a non-string title, ZERO or MORE-THAN-ONE body key, or any malformed body/action RAISES :class:ValueError rather than rendering a half-formed panel.

Parameters

  • spec: Any — (no description)