Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 25 additions & 13 deletions dev-notes/architecture/phase-21-extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ title: "Phase 21: Extensions"

Tau extensions are Python modules that customize a coding session: they add
tools and slash commands, observe the agent event stream, and intercept tool
calls, tool results, and user input. The design is a deliberate port of Pi's
extension system (`packages/coding-agent/src/core/extensions/` in
calls, tool results, user input, and per-run system prompts. The design is a
deliberate port of Pi's extension system
(`packages/coding-agent/src/core/extensions/` in
`earendil-works/pi`) onto Tau's Python architecture, scoped so the core is
small while still supporting real extensions such as a Claude Code-style
subagents extension.
Expand All @@ -21,11 +22,11 @@ are called out inline as **Ruling:** notes.
`register_tool`, `register_command`, `on(event)`, `send_user_message`,
`append_entry`, and read access to session context.
- Support Pi's load-bearing hook semantics: `tool_call` (block/mutate),
`tool_result` (transform), `input` (transform/handle), plus observation of
every portable `AgentEvent`.
- Keep `tau_agent` untouched: the extension machinery lives entirely in
`tau_coding`, using existing seams (`AgentHarness.subscribe`, executor
wrapping, `CommandRegistry`, `CustomEntry`).
`tool_result` (transform), `input` (transform/handle),
`before_agent_start` (replace the run prompt), plus observation of every
portable `AgentEvent`.
- Keep extension policy in `tau_coding`; `tau_agent` exposes only portable
seams such as `AgentHarness.subscribe` and the run-scoped prompt argument.
- Isolate failures: a broken extension is a `ResourceDiagnostic`, never a
crashed session.

Expand All @@ -34,8 +35,8 @@ are called out inline as **Ruling:** notes.
- npm-style package management (`pi install`), provider registration,
custom TUI components/widgets (extension-authored Textual widgets),
custom **entry** renderers (`registerEntryRenderer`/`appendEntry`-rendered,
non-LLM-context cards), shortcut and flag registration, system-prompt
replacement, `context`/`before_provider_request` rewriting, and a project
non-LLM-context cards), shortcut and flag registration,
`context`/`before_provider_request` rewriting, and a project
trust store.
These have reserved names and documented extension points but no
implementation yet.
Expand Down Expand Up @@ -240,6 +241,7 @@ Lifecycle events, dispatched by the runtime:
| `session_start` | `SessionStartEvent(reason: "startup" \| "reload" \| "new" \| "resume" \| "branch")` | — |
| `session_shutdown` | `SessionShutdownEvent(reason)` | — |
| `input` | `InputEvent(text, source="interactive" \| "extension", streaming_behavior="steer" \| "follow_up" \| None)` | `InputHookResult(action="continue" \| "transform" \| "handled", text=None, message=None)` |
| `before_agent_start` | `BeforeAgentStartEvent(system_prompt, system_prompt_inputs)` | `BeforeAgentStartHookResult(system_prompt=None)` |
| `tool_call` | `ToolCallHookEvent(tool_name, arguments)` | `ToolCallHookResult(block=False, reason=None, arguments=None)` |
| `tool_result` | `ToolResultHookEvent(tool_name, arguments, result)` | `ToolResultHookResult(content=None, ok=None, details=None)` |

Expand Down Expand Up @@ -559,7 +561,7 @@ the extension runtime) and the **queued-message preview**
(`harness.py` `queue_update_event` reports queued content strings). Both are
raw-text views by design; only live transcripts (TUI + print mode) render.

## Hook wiring (how interception works without touching tau_agent)
## Hook wiring and the portable tau_agent seam

- **Observation** — the runtime subscribes one listener via
`AgentHarness.subscribe` and fans events out to extension handlers.
Expand All @@ -578,6 +580,13 @@ raw-text views by design; only live transcripts (TUI + print mode) render.
consumes the input: the prompt generator returns without yielding run
events, and the optional `message` is delivered through the UI bridge
notification channel.
- **`before_agent_start`** — `CodingSession` chains prompt replacements before
each `prompt` or `continue_` run, then passes the final value through a
keyword-only `AgentHarness` run argument. The harness keeps that local value
for every provider call in the tool loop and clears it in `finally`; its base
config and transcript are unchanged. The hook receives frozen, repr-hidden
prompt inputs with skill content omitted. Its fresh `ExtensionContext` also
exposes the current chained prompt, matching Pi's `ctx.getSystemPrompt()`.
- **`send_user_message` / `send_custom_message`** — both funnel through one
`_deliver_message` path. When a run is active, they map to
`queue_steering_message` / `queue_follow_up_message` (which build a
Expand Down Expand Up @@ -709,8 +718,9 @@ of the newer API seams (manifest, dialogs, renderers, `on_update`,
falling back to `send_user_message` on older builds — which also
exercises the idle `turn_requested` path.

Smaller examples: `hello_tool.py` (minimal tool) and `permission_gate.py`
(`tool_call` blocking for dangerous bash commands).
Smaller examples: `hello_tool.py` (minimal tool), `prompt_customizer.py`
(`before_agent_start` replacement), and `permission_gate.py` (`tool_call`
blocking for dangerous bash commands).

## Verification

Expand All @@ -719,7 +729,9 @@ Smaller examples: `hello_tool.py` (minimal tool) and `permission_gate.py`
isolation, sync-only `setup` enforcement, tool registration/override,
command registration/duplicate handling, event fan-out, `tool_call`
block + argument mutation, `tool_result` transform, `input`
transform/handled, send_user_message queueing and idle turn-request,
transform/handled, `before_agent_start` chaining/failure isolation and
run reset/tool-loop/transcript behavior, send_user_message queueing and idle
turn-request,
append_entry persistence and on-path replay, reload including module
purge and stale-listener replacement, runtime survival across
resume/new.
Expand Down
5 changes: 4 additions & 1 deletion dev-notes/architecture/phase-4-agent-harness.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,10 @@ config = AgentHarnessConfig(
)
```

The harness receives this config and uses it for every prompt or continuation.
The harness receives this config as the stable default. `prompt()` and
`continue_()` may receive a keyword-only run prompt; when omitted, they use the
configured value. A run prompt is cleared when that run settles and never
changes the config.

## Prompt flow

Expand Down
32 changes: 32 additions & 0 deletions examples/extensions/prompt_customizer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
"""Tau extension that adds a run-scoped system-prompt instruction.

Install by copying into `~/.tau/extensions/`, or run:

tau -e examples/extensions/prompt_customizer.py
"""

from typing import cast

from tau_coding.extensions import (
BeforeAgentStartEvent,
BeforeAgentStartHookResult,
ExtensionAPI,
ExtensionContext,
ExtensionHandler,
)


def _customize_prompt(
event: BeforeAgentStartEvent,
context: ExtensionContext,
) -> BeforeAgentStartHookResult:
del context
tools = ", ".join(event.system_prompt_inputs.tools) or "none"
return BeforeAgentStartHookResult(
system_prompt=f"{event.system_prompt}\n\nActive tools for this run: {tools}."
)


def setup(tau: ExtensionAPI) -> None:
"""Customize each agent run without changing the saved base prompt."""
tau.on("before_agent_start", cast(ExtensionHandler, _customize_prompt))
59 changes: 39 additions & 20 deletions src/tau_agent/harness.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ def __init__(
self._messages = list(messages)
self._listeners: list[EventListener] = []
self._current_signal: SimpleCancellationToken | None = None
self._active_system_prompt: str | None = None
self._running = False
self._steering_queue: deque[AgentMessage] = deque()
self._follow_up_queue: deque[AgentMessage] = deque()
Expand All @@ -84,6 +85,13 @@ def messages(self) -> tuple[AgentMessage, ...]:
def config(self) -> AgentHarnessConfig:
return self._config

@property
def system_prompt(self) -> str:
"""Return the active run prompt, or the configured base while idle."""
if self._active_system_prompt is not None:
return self._active_system_prompt
return self._config.system

@property
def is_running(self) -> bool:
return self._running
Expand Down Expand Up @@ -144,26 +152,34 @@ def pop_latest_follow_up(self) -> AgentMessage | None:
def pop_latest_steering(self) -> AgentMessage | None:
return self._steering_queue.pop() if self._steering_queue else None

def prompt_message(self, message: AgentMessage) -> AsyncIterator[AgentEvent]:
def prompt_message(
self,
message: AgentMessage,
*,
system: str | None = None,
) -> AsyncIterator[AgentEvent]:
self._ensure_not_running()
self._running = True
return self._run(prompts=(message,))
return self._run(prompts=(message,), system=system)

def prompt(self, content: str) -> AsyncIterator[AgentEvent]:
return self.prompt_message(UserMessage(content=content))
def prompt(self, content: str, *, system: str | None = None) -> AsyncIterator[AgentEvent]:
return self.prompt_message(UserMessage(content=content), system=system)

def continue_(self) -> AsyncIterator[AgentEvent]:
def continue_(self, *, system: str | None = None) -> AsyncIterator[AgentEvent]:
self._ensure_not_running()
self._running = True
return self._run()
return self._run(system=system)

async def _run(
self,
*,
prompts: Sequence[AgentMessage] = (),
system: str | None = None,
) -> AsyncIterator[AgentEvent]:
signal = SimpleCancellationToken()
self._current_signal = signal
effective_system = self._config.system if system is None else system
self._active_system_prompt = effective_system
try:
# Repair dangling tool calls here, not in prompt()/continue_(),
# so the synthetic results flow through events and reach push
Expand All @@ -174,7 +190,7 @@ async def _run(
async for event in run_agent_loop(
provider=self._config.provider,
model=self._config.model,
system=self._config.system,
system=effective_system,
messages=self._messages,
prompts=prompts,
prelude_messages=repairs,
Expand All @@ -190,19 +206,22 @@ async def _run(
await self._notify(event)
yield event
finally:
if signal.is_cancelled():
repaired_from = len(self._messages)
self._append_interrupted_tool_results()
# The consumer is usually gone here; push the repairs to
# subscribers. Listener errors are suppressed; cancellation
# itself is not.
for message in self._messages[repaired_from:]:
with suppress(Exception):
await self._notify(MessageStartEvent(message=message))
await self._notify(MessageEndEvent(message=message))
if self._current_signal is signal:
self._current_signal = None
self._running = False
try:
if signal.is_cancelled():
repaired_from = len(self._messages)
self._append_interrupted_tool_results()
# The consumer is usually gone here; push the repairs to
# subscribers. Listener errors are suppressed; cancellation
# itself is not.
for message in self._messages[repaired_from:]:
with suppress(Exception):
await self._notify(MessageStartEvent(message=message))
await self._notify(MessageEndEvent(message=message))
finally:
if self._current_signal is signal:
self._current_signal = None
self._active_system_prompt = None
self._running = False

async def _notify(self, event: AgentEvent) -> None:
for listener in list(self._listeners):
Expand Down
20 changes: 20 additions & 0 deletions src/tau_coding/data/docs/extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,26 @@ Project extensions cannot approve themselves. They execute arbitrary Python and
remain disabled without both approval and the explicit code opt-in. Trust is not
a process/filesystem/network/tool/model sandbox.

## Per-run system prompts

Register `before_agent_start` to replace the system prompt for one agent run.
Handlers receive `BeforeAgentStartEvent.system_prompt` and a typed
`system_prompt_inputs` snapshot, then may return
`BeforeAgentStartHookResult(system_prompt=...)`. Handlers run in registration
order and each sees the prior replacement. The final prompt remains active for
tool-loop requests, then the next run starts again from the session's base
prompt. It is never added to the transcript.

During this hook, both `event.system_prompt` and `context.system_prompt` expose
the current chained value.

Skill metadata includes `disable_model_invocation`, matching whether a skill is
eligible for model invocation. Prompt inputs can contain project instructions
and paths. Their container types hide values from `repr` and Tau diagnostics,
but extensions should still treat
explicitly accessed fields as sensitive. See
`examples/extensions/prompt_customizer.py` for a complete extension.

## Development checklist

1. Read this document and the closest installed example under `examples/extensions/` completely before implementing.
Expand Down
32 changes: 32 additions & 0 deletions src/tau_coding/data/examples/extensions/prompt_customizer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
"""Tau extension that adds a run-scoped system-prompt instruction.

Install by copying into `~/.tau/extensions/`, or run:

tau -e examples/extensions/prompt_customizer.py
"""

from typing import cast

from tau_coding.extensions import (
BeforeAgentStartEvent,
BeforeAgentStartHookResult,
ExtensionAPI,
ExtensionContext,
ExtensionHandler,
)


def _customize_prompt(
event: BeforeAgentStartEvent,
context: ExtensionContext,
) -> BeforeAgentStartHookResult:
del context
tools = ", ".join(event.system_prompt_inputs.tools) or "none"
return BeforeAgentStartHookResult(
system_prompt=f"{event.system_prompt}\n\nActive tools for this run: {tools}."
)


def setup(tau: ExtensionAPI) -> None:
"""Customize each agent run without changing the saved base prompt."""
tau.on("before_agent_start", cast(ExtensionHandler, _customize_prompt))
3 changes: 2 additions & 1 deletion src/tau_coding/data/release-notes/releases.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@
"date": "2026-08-17",
"sections": {
"New": [
"Keep user-triggered skills out of the model-facing system prompt with disable-model-invocation frontmatter while retaining explicit invocation, picker, autocomplete, reload, and sidebar support."
"Keep user-triggered skills out of the model-facing system prompt with disable-model-invocation frontmatter while retaining explicit invocation, picker, autocomplete, reload, and sidebar support.",
"Let extensions replace the system prompt for one agent run through a chained before_agent_start hook without persisting the replacement."
],
"Changed": [
"Refine sidebar skill and prompt headings with bold labels, muted metadata, and no hover or focus background highlights."
Expand Down
7 changes: 7 additions & 0 deletions src/tau_coding/extensions/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
AGENT_EVENT_TYPES,
AGENT_EVENT_WILDCARD,
LIFECYCLE_EVENT_TYPES,
BeforeAgentStartEvent,
BeforeAgentStartHookResult,
ComponentBridge,
CustomMessageMarkup,
CustomMessageView,
Expand Down Expand Up @@ -49,11 +51,14 @@
ExtensionRuntime,
InputHookOutcome,
)
from tau_coding.system_prompt import SystemPromptInputs, SystemPromptSkill

__all__ = [
"AGENT_EVENT_TYPES",
"AGENT_EVENT_WILDCARD",
"LIFECYCLE_EVENT_TYPES",
"BeforeAgentStartEvent",
"BeforeAgentStartHookResult",
"BoundSession",
"ComponentBridge",
"CustomMessageMarkup",
Expand Down Expand Up @@ -84,6 +89,8 @@
"SessionShutdownEvent",
"SessionStartEvent",
"StderrUiBridge",
"SystemPromptInputs",
"SystemPromptSkill",
"ToolCallHookEvent",
"ToolCallHookResult",
"ToolResultHookEvent",
Expand Down
Loading