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 OWNExtensionHandlersbucket in the sessionExtensionRunner(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. LeftNonefor 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 inAgentSession._bind_extension_apiand 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_entryis bound (e.g. a bareExtensionAPI()). Fail-Early: raise rather than silently drop the entry into a RAM store that evaporates on restart.ValueError— propagated from_append_custom_entrywhencustom_typeis empty ordatais 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— iftopicis 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 (anAgentEventfor notify events; a(event_dict, ctx)pair for hook events).
Returns
An unsubscribe function.
Raises
RuntimeError— registering the retiredcontexthook (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 (aregister_commandname). 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 argsfills.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—keyorcommandis not a non-empty string, orkeycontains whitespace (a chord tail is a single key token — Fail-Early rather than silently binding an unreachable key).TypeError—argsis not a string, ordescriptionis neither a string norNone.
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 anAgentToolResult-shaped value (may be sync or async).ctxis the boundExtensionContext.
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— ifparametersis not a dict orexecuteis 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_messageis bound (e.g. a bareExtensionAPI()). 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_messagewhenmessagelackscontentorcustomType.
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 currentprompt()and re-enters within the same call."nextTurn": queued for the nextprompt()."steer"(docs/SUBMISSION-LIFECYCLE.md phase 4): delivered by the loop running RIGHT NOW, before its next LLM call — pi'ssendUserMessage(..., {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— ifdeliver_asis not"followUp","nextTurn"or"steer".RuntimeError— if no session with a message queue is bound (e.g. a bareExtensionAPI()with no session). Fail-Early: raise rather than silently drop the message (the oldhasattrno-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 returnsaccepted=Falsewith arejection_reasonrather than raising or dropping silently. See :meth:AgentSession.submitfor 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.Submissionconstruction (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— Jupyterallow_stdin— whether code running under THIS submission may prompt a human. DefaultFalse: 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-defaultspolicy they raise :class:HeadlessDialogErrorinstead of putting a modal in front of whoever happens to be at the terminal. PassTrueonly 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 anAgentSession(nothing to submit to).ValueError— ifcorrelationcarries 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 noAgentSession(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— ifcorrelationcarries 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 = TruebutSUBJECTSis 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 = Trueand validSUBJECTS, 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_SCHEMAand 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 theCompactionResult.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 ofprompt()(the same site as auto-compaction). ReturnsNoneimmediately; 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), aModel, or None for the session's current model.constraints: Any = None— an optionaltau_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 anavigatetoentry_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 thenavigate+appendin-place fork.mode="export": copy the session into a NEW file viaSession.fork(session_store.py:347; the source log is never touched), optionally positioning the new file's cursor atentry_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¶
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 resolvename).
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 genericExtensionFormScreen; a real human fills it. Returns the{name: value}dict on submit, orNoneon 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, soallow_user_input=Falseroutes 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=defaultsreturns each field's declared default (:func:form_headless_value); with NOformpolicy 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_sinkis 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_sinkis installed) → emits one{"type": "extension", "kind": "panel", "key": …, "spec": …}record through the sink (specis the normalized dict, ornullon clear) so a parent reading a childtau -p --mode jsonstream 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— ifkeyis not a non-empty string (Fail-Early: a panel with no key has nothing to update or clear); or (via :func:validate_panel_spec) ifspecis 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_sinkis installed) → emits one{"type": "extension", "kind": "status", …}record through the sink so a parent reading a childtau -p --mode jsonstream 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— ifkeyis 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'sallow_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 noappend_session_info(e.g. the SDK's RAM-onlyInMemorySessionLog— session naming needs a file-backed log).ValueError—nameis 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 noname(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 aselect/multiselectvalue 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)