tau-coding-agent 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tau/__init__.py +0 -0
- tau/agent/__init__.py +11 -0
- tau/agent/prompt/__init__.py +10 -0
- tau/agent/prompt/builder.py +302 -0
- tau/agent/prompt/types.py +33 -0
- tau/agent/service.py +369 -0
- tau/agent/types.py +61 -0
- tau/auth/manager.py +247 -0
- tau/auth/storage.py +82 -0
- tau/auth/types.py +41 -0
- tau/builtins/__init__.py +4 -0
- tau/builtins/__pycache__/__init__.cpython-313.pyc +0 -0
- tau/builtins/__pycache__/__init__.cpython-314.pyc +0 -0
- tau/builtins/commands/__init__.py +41 -0
- tau/builtins/commands/__pycache__/__init__.cpython-313.pyc +0 -0
- tau/builtins/commands/__pycache__/__init__.cpython-314.pyc +0 -0
- tau/builtins/commands/__pycache__/clear.cpython-313.pyc +0 -0
- tau/builtins/commands/__pycache__/clear.cpython-314.pyc +0 -0
- tau/builtins/commands/__pycache__/compact.cpython-313.pyc +0 -0
- tau/builtins/commands/__pycache__/compact.cpython-314.pyc +0 -0
- tau/builtins/commands/__pycache__/reload.cpython-313.pyc +0 -0
- tau/builtins/commands/__pycache__/reload.cpython-314.pyc +0 -0
- tau/builtins/commands/__pycache__/session.cpython-313.pyc +0 -0
- tau/builtins/commands/__pycache__/session.cpython-314.pyc +0 -0
- tau/builtins/commands/clear.py +16 -0
- tau/builtins/commands/compact.py +28 -0
- tau/builtins/commands/reload.py +27 -0
- tau/builtins/commands/session.py +19 -0
- tau/builtins/extensions/footer/__init__.py +76 -0
- tau/builtins/extensions/footer/__pycache__/__init__.cpython-313.pyc +0 -0
- tau/builtins/extensions/footer/__pycache__/git.cpython-313.pyc +0 -0
- tau/builtins/extensions/footer/__pycache__/model.cpython-313.pyc +0 -0
- tau/builtins/extensions/footer/__pycache__/utils.cpython-313.pyc +0 -0
- tau/builtins/extensions/footer/git.py +26 -0
- tau/builtins/extensions/footer/model.py +69 -0
- tau/builtins/extensions/footer/utils.py +44 -0
- tau/builtins/extensions/header/__init__.py +18 -0
- tau/builtins/extensions/header/__pycache__/__init__.cpython-313.pyc +0 -0
- tau/builtins/models/__init__.py +0 -0
- tau/builtins/models/__pycache__/__init__.cpython-313.pyc +0 -0
- tau/builtins/models/__pycache__/text.cpython-313.pyc +0 -0
- tau/builtins/models/audio.py +43 -0
- tau/builtins/models/image.py +43 -0
- tau/builtins/models/text.py +482 -0
- tau/builtins/models/video.py +40 -0
- tau/builtins/prompts/commit.md +7 -0
- tau/builtins/prompts/docs.md +7 -0
- tau/builtins/prompts/explain.md +7 -0
- tau/builtins/prompts/fix.md +7 -0
- tau/builtins/prompts/refactor.md +7 -0
- tau/builtins/prompts/review.md +7 -0
- tau/builtins/prompts/test.md +7 -0
- tau/builtins/providers/__init__.py +0 -0
- tau/builtins/providers/__pycache__/__init__.cpython-313.pyc +0 -0
- tau/builtins/providers/__pycache__/text.cpython-313.pyc +0 -0
- tau/builtins/providers/audio.py +10 -0
- tau/builtins/providers/image.py +9 -0
- tau/builtins/providers/text.py +33 -0
- tau/builtins/providers/video.py +6 -0
- tau/builtins/skills/code-review/SKILL.md +4 -0
- tau/builtins/skills/debug/SKILL.md +4 -0
- tau/builtins/skills/git-commit/SKILL.md +4 -0
- tau/builtins/themes/dark.yaml +1 -0
- tau/builtins/themes/light.yaml +46 -0
- tau/builtins/tools/__init__.py +73 -0
- tau/builtins/tools/__pycache__/__init__.cpython-313.pyc +0 -0
- tau/builtins/tools/__pycache__/__init__.cpython-314.pyc +0 -0
- tau/builtins/tools/__pycache__/bash.cpython-313.pyc +0 -0
- tau/builtins/tools/__pycache__/bash.cpython-314.pyc +0 -0
- tau/builtins/tools/__pycache__/edit.cpython-313.pyc +0 -0
- tau/builtins/tools/__pycache__/edit.cpython-314.pyc +0 -0
- tau/builtins/tools/__pycache__/glob.cpython-313.pyc +0 -0
- tau/builtins/tools/__pycache__/glob.cpython-314.pyc +0 -0
- tau/builtins/tools/__pycache__/grep.cpython-313.pyc +0 -0
- tau/builtins/tools/__pycache__/grep.cpython-314.pyc +0 -0
- tau/builtins/tools/__pycache__/ls.cpython-313.pyc +0 -0
- tau/builtins/tools/__pycache__/ls.cpython-314.pyc +0 -0
- tau/builtins/tools/__pycache__/read.cpython-313.pyc +0 -0
- tau/builtins/tools/__pycache__/read.cpython-314.pyc +0 -0
- tau/builtins/tools/__pycache__/terminal.cpython-313.pyc +0 -0
- tau/builtins/tools/__pycache__/terminal.cpython-314.pyc +0 -0
- tau/builtins/tools/__pycache__/write.cpython-313.pyc +0 -0
- tau/builtins/tools/__pycache__/write.cpython-314.pyc +0 -0
- tau/builtins/tools/edit.py +215 -0
- tau/builtins/tools/glob.py +112 -0
- tau/builtins/tools/grep.py +146 -0
- tau/builtins/tools/ls.py +135 -0
- tau/builtins/tools/read.py +122 -0
- tau/builtins/tools/terminal.py +150 -0
- tau/builtins/tools/write.py +105 -0
- tau/commands/__init__.py +10 -0
- tau/commands/registry.py +71 -0
- tau/commands/types.py +33 -0
- tau/console/__init__.py +0 -0
- tau/console/cli.py +266 -0
- tau/console/commands/__init__.py +0 -0
- tau/console/commands/auth.py +193 -0
- tau/console/commands/packages.py +104 -0
- tau/console/commands/update.py +76 -0
- tau/core/__init__.py +0 -0
- tau/core/registry.py +102 -0
- tau/engine/__init__.py +47 -0
- tau/engine/service.py +768 -0
- tau/engine/types.py +163 -0
- tau/extensions/__init__.py +28 -0
- tau/extensions/api.py +928 -0
- tau/extensions/context.py +462 -0
- tau/extensions/events.py +70 -0
- tau/extensions/loader.py +386 -0
- tau/extensions/runtime.py +184 -0
- tau/extensions/settings.py +137 -0
- tau/hooks/__init__.py +112 -0
- tau/hooks/engine.py +237 -0
- tau/hooks/inference.py +21 -0
- tau/hooks/runtime.py +126 -0
- tau/hooks/service.py +121 -0
- tau/hooks/session.py +117 -0
- tau/hooks/tui.py +61 -0
- tau/hooks/types.py +72 -0
- tau/inference/__init__.py +180 -0
- tau/inference/api/__init__.py +0 -0
- tau/inference/api/audio/__init__.py +0 -0
- tau/inference/api/audio/base.py +29 -0
- tau/inference/api/audio/builtins.py +15 -0
- tau/inference/api/audio/elevenlabs_audio.py +183 -0
- tau/inference/api/audio/gemini_audio.py +95 -0
- tau/inference/api/audio/openai_audio.py +159 -0
- tau/inference/api/audio/registry.py +15 -0
- tau/inference/api/audio/sarvam_audio.py +163 -0
- tau/inference/api/audio/service.py +103 -0
- tau/inference/api/audio/utils.py +47 -0
- tau/inference/api/image/__init__.py +0 -0
- tau/inference/api/image/base.py +17 -0
- tau/inference/api/image/builtins.py +8 -0
- tau/inference/api/image/gemini_image.py +77 -0
- tau/inference/api/image/openai_image.py +103 -0
- tau/inference/api/image/openrouter.py +144 -0
- tau/inference/api/image/registry.py +15 -0
- tau/inference/api/image/service.py +71 -0
- tau/inference/api/registry.py +82 -0
- tau/inference/api/text/__init__.py +0 -0
- tau/inference/api/text/anthropic_claude_code.py +222 -0
- tau/inference/api/text/anthropic_messages.py +196 -0
- tau/inference/api/text/base.py +40 -0
- tau/inference/api/text/builtins.py +19 -0
- tau/inference/api/text/gemini_generate.py +234 -0
- tau/inference/api/text/github_copilot_chat.py +172 -0
- tau/inference/api/text/google_antigravity.py +522 -0
- tau/inference/api/text/mistral_chat.py +284 -0
- tau/inference/api/text/ollama_chat.py +200 -0
- tau/inference/api/text/openai_codex_responses.py +497 -0
- tau/inference/api/text/openai_completions.py +227 -0
- tau/inference/api/text/openai_responses.py +235 -0
- tau/inference/api/text/registry.py +50 -0
- tau/inference/api/text/service.py +297 -0
- tau/inference/api/text/types.py +7 -0
- tau/inference/api/text/utils.py +228 -0
- tau/inference/api/video/__init__.py +0 -0
- tau/inference/api/video/base.py +26 -0
- tau/inference/api/video/builtins.py +7 -0
- tau/inference/api/video/fal_video.py +119 -0
- tau/inference/api/video/openrouter_video.py +142 -0
- tau/inference/api/video/registry.py +15 -0
- tau/inference/api/video/service.py +72 -0
- tau/inference/model/__init__.py +0 -0
- tau/inference/model/registry.py +102 -0
- tau/inference/model/types.py +65 -0
- tau/inference/provider/__init__.py +0 -0
- tau/inference/provider/oauth/__init__.py +35 -0
- tau/inference/provider/oauth/anthropic_claude_code.py +286 -0
- tau/inference/provider/oauth/github_copilot.py +333 -0
- tau/inference/provider/oauth/google_antigravity.py +258 -0
- tau/inference/provider/oauth/openai_codex.py +309 -0
- tau/inference/provider/oauth/pkce.py +14 -0
- tau/inference/provider/oauth/types.py +46 -0
- tau/inference/provider/oauth/utils.py +154 -0
- tau/inference/provider/registry.py +141 -0
- tau/inference/provider/types.py +114 -0
- tau/inference/types.py +549 -0
- tau/inference/utils.py +219 -0
- tau/message/__init__.py +0 -0
- tau/message/types.py +482 -0
- tau/message/utils.py +178 -0
- tau/packages/__init__.py +11 -0
- tau/packages/manager.py +190 -0
- tau/packages/types.py +20 -0
- tau/packages/utils.py +67 -0
- tau/prompts/expand.py +58 -0
- tau/prompts/loader.py +69 -0
- tau/prompts/registry.py +45 -0
- tau/prompts/types.py +24 -0
- tau/rpc/__init__.py +8 -0
- tau/rpc/mode.py +783 -0
- tau/rpc/types.py +252 -0
- tau/runtime/service.py +759 -0
- tau/runtime/types.py +303 -0
- tau/session/branch_summarization.py +312 -0
- tau/session/compaction.py +646 -0
- tau/session/manager.py +652 -0
- tau/session/types.py +188 -0
- tau/session/utils.py +233 -0
- tau/settings/manager.py +1077 -0
- tau/settings/paths.py +150 -0
- tau/settings/storage.py +63 -0
- tau/settings/types.py +173 -0
- tau/settings/utils.py +25 -0
- tau/skills/loader.py +91 -0
- tau/skills/registry.py +70 -0
- tau/skills/types.py +25 -0
- tau/themes/loader.py +238 -0
- tau/themes/registry.py +108 -0
- tau/themes/types.py +19 -0
- tau/tool/__init__.py +3 -0
- tau/tool/registry.py +117 -0
- tau/tool/render.py +21 -0
- tau/tool/types.py +244 -0
- tau/trust/__init__.py +13 -0
- tau/trust/manager.py +80 -0
- tau/trust/types.py +14 -0
- tau/trust/utils.py +72 -0
- tau/tui/__init__.py +54 -0
- tau/tui/agent_hooks.py +346 -0
- tau/tui/ansi.py +330 -0
- tau/tui/app.py +540 -0
- tau/tui/autocomplete.py +33 -0
- tau/tui/capabilities.py +119 -0
- tau/tui/commands/__init__.py +3 -0
- tau/tui/commands/appearance.py +498 -0
- tau/tui/commands/auth.py +232 -0
- tau/tui/commands/context.py +38 -0
- tau/tui/commands/misc.py +82 -0
- tau/tui/commands/model.py +118 -0
- tau/tui/commands/session.py +464 -0
- tau/tui/component.py +268 -0
- tau/tui/components/__init__.py +0 -0
- tau/tui/components/autocomplete_manager.py +267 -0
- tau/tui/components/autocomplete_picker.py +143 -0
- tau/tui/components/box.py +90 -0
- tau/tui/components/command_palette.py +144 -0
- tau/tui/components/dynamic_border.py +19 -0
- tau/tui/components/file_picker.py +233 -0
- tau/tui/components/image.py +181 -0
- tau/tui/components/inline_selector.py +71 -0
- tau/tui/components/layout.py +1194 -0
- tau/tui/components/message_list.py +692 -0
- tau/tui/components/modal.py +97 -0
- tau/tui/components/model_palette.py +204 -0
- tau/tui/components/picker_overlay.py +174 -0
- tau/tui/components/prompt_overlay.py +236 -0
- tau/tui/components/resume_modal.py +372 -0
- tau/tui/components/select_list.py +222 -0
- tau/tui/components/settings_modal.py +274 -0
- tau/tui/components/settings_schema.py +203 -0
- tau/tui/components/spinner.py +119 -0
- tau/tui/components/text_input.py +396 -0
- tau/tui/components/text_prompt.py +82 -0
- tau/tui/components/tree_select_list.py +580 -0
- tau/tui/components/trust_screen.py +97 -0
- tau/tui/diff.py +114 -0
- tau/tui/fuzzy.py +99 -0
- tau/tui/input.py +496 -0
- tau/tui/input_handler.py +716 -0
- tau/tui/keybindings.py +87 -0
- tau/tui/markdown.py +286 -0
- tau/tui/message_renderers.py +31 -0
- tau/tui/overlay.py +326 -0
- tau/tui/renderer.py +378 -0
- tau/tui/terminal.py +499 -0
- tau/tui/theme.py +148 -0
- tau/tui/tui.py +544 -0
- tau/tui/ui_context.py +768 -0
- tau/tui/utils.py +20 -0
- tau/utils/__init__.py +0 -0
- tau/utils/http_proxy.py +221 -0
- tau/utils/image_processing.py +172 -0
- tau/utils/secrets.py +59 -0
- tau/utils/version_check.py +60 -0
- tau_coding_agent-0.1.0.dist-info/METADATA +177 -0
- tau_coding_agent-0.1.0.dist-info/RECORD +283 -0
- tau_coding_agent-0.1.0.dist-info/WHEEL +5 -0
- tau_coding_agent-0.1.0.dist-info/entry_points.txt +2 -0
- tau_coding_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
- tau_coding_agent-0.1.0.dist-info/top_level.txt +1 -0
tau/runtime/service.py
ADDED
|
@@ -0,0 +1,759 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from tau.runtime.types import RuntimeConfig, RuntimeContext
|
|
8
|
+
from tau.agent.service import Agent
|
|
9
|
+
from tau.agent.types import PromptOptions
|
|
10
|
+
from tau.commands.registry import CommandRegistry
|
|
11
|
+
from tau.commands.types import ParsedCommand
|
|
12
|
+
from tau.hooks.session import (
|
|
13
|
+
SessionStartEvent,
|
|
14
|
+
SessionStartReason,
|
|
15
|
+
SessionShutdownEvent,
|
|
16
|
+
SessionShutdownReason,
|
|
17
|
+
SessionBeforeSwitchEvent,
|
|
18
|
+
SessionBeforeSwitchReason,
|
|
19
|
+
SessionBeforeSwitchResult,
|
|
20
|
+
SessionBeforeForkEvent,
|
|
21
|
+
SessionBeforeForkResult,
|
|
22
|
+
SessionBeforeTreeEvent,
|
|
23
|
+
SessionBeforeTreeResult,
|
|
24
|
+
TreePreparation,
|
|
25
|
+
SessionTreeEvent,
|
|
26
|
+
)
|
|
27
|
+
from tau.hooks.runtime import InputEvent, InputEventResult, RuntimeReadyEvent, RuntimeStopEvent
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from tau.tui.components.layout import Layout
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Runtime:
|
|
34
|
+
"""
|
|
35
|
+
Orchestrates the session lifecycle: creation, switching, and forking
|
|
36
|
+
on top of Agent and RuntimeContext.
|
|
37
|
+
|
|
38
|
+
Usage:
|
|
39
|
+
runtime = await Runtime.create(config)
|
|
40
|
+
await runtime.invoke("explain this code")
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
context: RuntimeContext,
|
|
46
|
+
config: RuntimeConfig,
|
|
47
|
+
) -> None:
|
|
48
|
+
self._context = context
|
|
49
|
+
self._config = config
|
|
50
|
+
self.commands = CommandRegistry(runtime=self)
|
|
51
|
+
self._layout: Layout | None = None
|
|
52
|
+
self._stopped: bool = False
|
|
53
|
+
self.version_check_task: asyncio.Task[str | None] | None = None
|
|
54
|
+
if context.agent is not None:
|
|
55
|
+
context.agent._runtime = self
|
|
56
|
+
# Bind runtime ref so (event, ctx) handlers resolve live state
|
|
57
|
+
if context.ext_runtime is not None:
|
|
58
|
+
context.ext_runtime.runtime_ref.runtime = self
|
|
59
|
+
# Register extension commands into the command registry
|
|
60
|
+
if context.ext_runtime is not None:
|
|
61
|
+
for cmd in context.ext_runtime.get_commands():
|
|
62
|
+
self.commands.register(cmd)
|
|
63
|
+
|
|
64
|
+
# -------------------------------------------------------------------------
|
|
65
|
+
# Factory
|
|
66
|
+
# -------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
@classmethod
|
|
69
|
+
async def create(cls, config: RuntimeConfig) -> Runtime:
|
|
70
|
+
"""Create a fully initialised Runtime from config and fire the session_start event."""
|
|
71
|
+
context = await RuntimeContext.create(config)
|
|
72
|
+
runtime = cls(context=context, config=config)
|
|
73
|
+
runtime._start_version_check()
|
|
74
|
+
await runtime._emit_session_start(SessionStartReason.Startup)
|
|
75
|
+
# Runtime is now fully wired (engine, agent, tools, extensions) and the
|
|
76
|
+
# session has started, but no mode-specific loop (TUI/print/rpc) has begun.
|
|
77
|
+
# Extensions can hook `runtime_ready` to start background work from here.
|
|
78
|
+
await context.hooks.emit(RuntimeReadyEvent())
|
|
79
|
+
return runtime
|
|
80
|
+
|
|
81
|
+
def _start_version_check(self) -> None:
|
|
82
|
+
from tau.settings.paths import get_app_version
|
|
83
|
+
from tau.utils.version_check import check_for_new_version
|
|
84
|
+
|
|
85
|
+
self.version_check_task = asyncio.ensure_future(check_for_new_version(get_app_version()))
|
|
86
|
+
|
|
87
|
+
# -------------------------------------------------------------------------
|
|
88
|
+
# Public properties
|
|
89
|
+
# -------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def agent(self) -> Agent | None:
|
|
93
|
+
"""Get the current agent instance."""
|
|
94
|
+
return self._context.agent
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def hooks(self):
|
|
98
|
+
"""Get the hooks dispatcher."""
|
|
99
|
+
return self._context.hooks
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def session_manager(self):
|
|
103
|
+
"""Get the session manager."""
|
|
104
|
+
return self._context.session_manager
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def settings_manager(self):
|
|
108
|
+
"""Get the settings manager."""
|
|
109
|
+
return self._context.settings_manager
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def extension_runtime(self):
|
|
113
|
+
"""Get the extension runtime."""
|
|
114
|
+
return self._context.ext_runtime
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def extension_shortcuts(self):
|
|
118
|
+
"""Get all registered extension keyboard shortcuts."""
|
|
119
|
+
if self._context.ext_runtime is not None:
|
|
120
|
+
return self._context.ext_runtime.get_shortcuts()
|
|
121
|
+
return []
|
|
122
|
+
|
|
123
|
+
def set_layout(self, layout: Layout) -> None:
|
|
124
|
+
"""Set the TUI layout, making it available to internal services."""
|
|
125
|
+
self._layout = layout
|
|
126
|
+
|
|
127
|
+
def notify(self, message: str) -> None:
|
|
128
|
+
"""Post a system status note to the active TUI, if attached."""
|
|
129
|
+
if self._layout is None:
|
|
130
|
+
return
|
|
131
|
+
import time
|
|
132
|
+
from tau.message.types import CustomMessage, LinesContent
|
|
133
|
+
|
|
134
|
+
msg = CustomMessage(
|
|
135
|
+
custom_type="system",
|
|
136
|
+
timestamp=time.time(),
|
|
137
|
+
contents=[LinesContent(lines=[message, ""])],
|
|
138
|
+
)
|
|
139
|
+
self._layout.add_message(msg)
|
|
140
|
+
|
|
141
|
+
# -------------------------------------------------------------------------
|
|
142
|
+
# Core input entry point
|
|
143
|
+
# -------------------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
async def user_input(self, text: str, options: PromptOptions | None = None) -> None:
|
|
146
|
+
"""Accept raw user text. ! runs a shell command; / goes to CommandRegistry; everything else to the agent."""
|
|
147
|
+
match text.strip():
|
|
148
|
+
case "":
|
|
149
|
+
return
|
|
150
|
+
case t if t.startswith("!!"):
|
|
151
|
+
await self.execute_terminal(t[2:].strip(), exclude=True)
|
|
152
|
+
case t if t.startswith("!"):
|
|
153
|
+
await self.execute_terminal(t[1:].strip())
|
|
154
|
+
case t if t.startswith("/skill:"):
|
|
155
|
+
skill_part = t[7:].strip().split(None, 1)
|
|
156
|
+
skill_name = skill_part[0].lower() if skill_part else ""
|
|
157
|
+
skill_args = skill_part[1] if len(skill_part) > 1 else ""
|
|
158
|
+
from tau.skills.registry import skill_registry
|
|
159
|
+
|
|
160
|
+
skill = skill_registry.get(skill_name)
|
|
161
|
+
if skill is not None:
|
|
162
|
+
expanded = (
|
|
163
|
+
f'<skill name="{skill.name}" location="{skill.file_path}">\n'
|
|
164
|
+
f"References are relative to {skill.base_dir}.\n\n"
|
|
165
|
+
f"{skill.content}\n</skill>"
|
|
166
|
+
)
|
|
167
|
+
if skill_args:
|
|
168
|
+
expanded += f"\n\n{skill_args}"
|
|
169
|
+
await self.invoke(expanded, options)
|
|
170
|
+
case t if t.startswith("/"):
|
|
171
|
+
parts = t[1:].strip().split()
|
|
172
|
+
name, args = parts[0].lower(), parts[1:]
|
|
173
|
+
cmd = ParsedCommand(name=name, args=args, raw=t)
|
|
174
|
+
dispatched = await self.commands.dispatch(cmd)
|
|
175
|
+
if not dispatched:
|
|
176
|
+
from tau.prompts.registry import prompt_registry
|
|
177
|
+
|
|
178
|
+
expanded = prompt_registry.expand(name, " ".join(args))
|
|
179
|
+
if expanded is not None:
|
|
180
|
+
await self.invoke(expanded, options)
|
|
181
|
+
case t:
|
|
182
|
+
await self.invoke(t, options)
|
|
183
|
+
|
|
184
|
+
async def set_model(self, model_id: str, provider: str | None = None) -> None:
|
|
185
|
+
"""Swap the active model. Only safe to call when the agent is idle."""
|
|
186
|
+
from tau.inference.api.text.service import TextLLM
|
|
187
|
+
from tau.hooks.tui import ModelSelectEvent
|
|
188
|
+
|
|
189
|
+
agent = self._context.agent
|
|
190
|
+
if agent is None:
|
|
191
|
+
return
|
|
192
|
+
old_model = agent._engine.llm.model
|
|
193
|
+
new_llm = TextLLM(model_id=model_id, provider=provider)
|
|
194
|
+
if new_llm.model.thinking:
|
|
195
|
+
new_llm.api.options.thinking_level = new_llm.model.default_thinking_level
|
|
196
|
+
agent._engine.set_llm(new_llm)
|
|
197
|
+
agent._context_window = new_llm.model.context_window or 128_000
|
|
198
|
+
await self._context.hooks.emit(
|
|
199
|
+
ModelSelectEvent(
|
|
200
|
+
model=new_llm.model,
|
|
201
|
+
previous_model=old_model,
|
|
202
|
+
source="set",
|
|
203
|
+
)
|
|
204
|
+
)
|
|
205
|
+
session = self._context.session_manager
|
|
206
|
+
if session is not None:
|
|
207
|
+
session.append_model_change(model_id, new_llm.provider_id)
|
|
208
|
+
|
|
209
|
+
sm = self._context.settings_manager
|
|
210
|
+
if sm is not None:
|
|
211
|
+
if provider:
|
|
212
|
+
sm.set_model_and_provider(provider, model_id)
|
|
213
|
+
else:
|
|
214
|
+
sm.set_model(model_id)
|
|
215
|
+
|
|
216
|
+
async def execute_terminal(self, cmd: str, exclude: bool = False) -> None:
|
|
217
|
+
"""Run a shell command, stream output chunks, persist to session, and emit events."""
|
|
218
|
+
import asyncio
|
|
219
|
+
from asyncio.subprocess import PIPE, STDOUT
|
|
220
|
+
from tau.message.types import TerminalExecutionMessage
|
|
221
|
+
from tau.hooks.types import (
|
|
222
|
+
TerminalExecutionEvent,
|
|
223
|
+
TerminalOutputEvent,
|
|
224
|
+
UserTerminalEvent,
|
|
225
|
+
UserTerminalResult,
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
exit_code: int | None = None
|
|
229
|
+
cancelled = False
|
|
230
|
+
cwd = str(self._context.session_manager.cwd)
|
|
231
|
+
|
|
232
|
+
# Let extensions intercept before the shell runs
|
|
233
|
+
terminal_results = await self._context.hooks.emit(
|
|
234
|
+
UserTerminalEvent(command=cmd, private=exclude, cwd=cwd)
|
|
235
|
+
)
|
|
236
|
+
for r in terminal_results:
|
|
237
|
+
if isinstance(r, UserTerminalResult) and r.handled:
|
|
238
|
+
msg = TerminalExecutionMessage(
|
|
239
|
+
command=cmd, output=r.output, exit_code=r.exit_code, exclude=exclude
|
|
240
|
+
)
|
|
241
|
+
sm = self._context.session_manager
|
|
242
|
+
if sm is not None:
|
|
243
|
+
sm.append_message(msg)
|
|
244
|
+
await self._context.hooks.emit(TerminalExecutionEvent(message=msg, streaming=False))
|
|
245
|
+
return
|
|
246
|
+
|
|
247
|
+
msg = TerminalExecutionMessage(command=cmd, output="", exclude=exclude)
|
|
248
|
+
|
|
249
|
+
await self._context.hooks.emit(TerminalExecutionEvent(message=msg, streaming=True))
|
|
250
|
+
|
|
251
|
+
try:
|
|
252
|
+
proc = await asyncio.create_subprocess_shell(
|
|
253
|
+
cmd.strip(),
|
|
254
|
+
stdout=PIPE,
|
|
255
|
+
stderr=STDOUT,
|
|
256
|
+
cwd=cwd,
|
|
257
|
+
)
|
|
258
|
+
if proc.stdout is not None:
|
|
259
|
+
async for line in proc.stdout:
|
|
260
|
+
msg.output += line.decode(errors="replace")
|
|
261
|
+
await self._context.hooks.emit(TerminalOutputEvent(message=msg))
|
|
262
|
+
await proc.wait()
|
|
263
|
+
exit_code = proc.returncode
|
|
264
|
+
except Exception as exc:
|
|
265
|
+
msg.output += f"error: {exc}"
|
|
266
|
+
cancelled = True
|
|
267
|
+
|
|
268
|
+
msg.output = msg.output.rstrip()
|
|
269
|
+
msg.exit_code = exit_code
|
|
270
|
+
msg.cancelled = cancelled
|
|
271
|
+
|
|
272
|
+
sm = self._context.session_manager
|
|
273
|
+
if sm is not None:
|
|
274
|
+
sm.append_message(msg)
|
|
275
|
+
|
|
276
|
+
await self._context.hooks.emit(TerminalExecutionEvent(message=msg, streaming=False))
|
|
277
|
+
|
|
278
|
+
async def invoke(self, text: str, options: PromptOptions | None = None) -> None:
|
|
279
|
+
"""Forward a plain prompt to the current session."""
|
|
280
|
+
if self._context.agent is None:
|
|
281
|
+
raise RuntimeError("No active session available.")
|
|
282
|
+
results = await self._context.hooks.emit(InputEvent(text=text))
|
|
283
|
+
for r in results:
|
|
284
|
+
if isinstance(r, InputEventResult) and r.action == "transform" and r.text is not None:
|
|
285
|
+
text = r.text
|
|
286
|
+
break
|
|
287
|
+
await self._context.agent.invoke(text, options)
|
|
288
|
+
|
|
289
|
+
async def reload_extensions(self):
|
|
290
|
+
"""Re-discover and reload extensions, skills, prompts, and settings.
|
|
291
|
+
|
|
292
|
+
Applies all changes to the live engine and rebuilds the system prompt
|
|
293
|
+
immediately — no new session required.
|
|
294
|
+
"""
|
|
295
|
+
from pathlib import Path
|
|
296
|
+
from tau.agent.prompt.builder import build_prompt
|
|
297
|
+
from tau.agent.prompt.types import PromptOptions
|
|
298
|
+
from tau.extensions.api import _RuntimeRef, LoadExtensionsResult
|
|
299
|
+
from tau.extensions.events import EventBus
|
|
300
|
+
from tau.extensions.loader import ExtensionLoader
|
|
301
|
+
from tau.extensions.runtime import ExtensionRuntime
|
|
302
|
+
from tau.settings.paths import get_extensions_dir
|
|
303
|
+
from tau.skills.registry import skill_registry
|
|
304
|
+
from tau.prompts.registry import prompt_registry
|
|
305
|
+
|
|
306
|
+
sm = self._context.settings_manager
|
|
307
|
+
if sm is None:
|
|
308
|
+
return LoadExtensionsResult()
|
|
309
|
+
|
|
310
|
+
# ── Settings ─────────────────────────────────────────────────────────
|
|
311
|
+
await sm.reload()
|
|
312
|
+
|
|
313
|
+
cwd = self._context.session_manager.cwd
|
|
314
|
+
|
|
315
|
+
# ── Resource discovery hook ──────────────────────────────────────────
|
|
316
|
+
from tau.hooks.types import ResourcesDiscoverEvent, ResourcesDiscoverResult
|
|
317
|
+
|
|
318
|
+
discover_results = await self._context.hooks.emit(ResourcesDiscoverEvent(cwd=str(cwd)))
|
|
319
|
+
extra_skill_paths: list[str] = []
|
|
320
|
+
extra_prompt_paths: list[str] = []
|
|
321
|
+
extra_theme_paths: list[str] = []
|
|
322
|
+
for r in discover_results:
|
|
323
|
+
if isinstance(r, ResourcesDiscoverResult):
|
|
324
|
+
extra_skill_paths.extend(r.skill_paths)
|
|
325
|
+
extra_prompt_paths.extend(r.prompt_paths)
|
|
326
|
+
extra_theme_paths.extend(r.theme_paths)
|
|
327
|
+
|
|
328
|
+
# ── Skills ───────────────────────────────────────────────────────────
|
|
329
|
+
skill_registry.reload(cwd=cwd, extra_paths=extra_skill_paths or None) # type: ignore[arg-type]
|
|
330
|
+
|
|
331
|
+
# ── Prompts ──────────────────────────────────────────────────────────
|
|
332
|
+
prompt_registry.reload(cwd=cwd, extra_paths=extra_prompt_paths or None) # type: ignore[arg-type]
|
|
333
|
+
|
|
334
|
+
# extra_theme_paths: load each directory into the theme registry
|
|
335
|
+
if extra_theme_paths:
|
|
336
|
+
from tau.themes.registry import theme_registry
|
|
337
|
+
import logging as _logging
|
|
338
|
+
|
|
339
|
+
_log = _logging.getLogger(__name__)
|
|
340
|
+
for tp in extra_theme_paths:
|
|
341
|
+
try:
|
|
342
|
+
from pathlib import Path as _Path
|
|
343
|
+
|
|
344
|
+
theme_registry.load_external(cwd=_Path(tp))
|
|
345
|
+
except Exception as _e:
|
|
346
|
+
_log.warning("resources_discover: failed to load theme path %r: %s", tp, _e)
|
|
347
|
+
|
|
348
|
+
old = self._context.ext_runtime
|
|
349
|
+
if old is not None:
|
|
350
|
+
for ext in old._extensions:
|
|
351
|
+
await self._emit_to_extension(ext, "extension_unload")
|
|
352
|
+
old.unsubscribe()
|
|
353
|
+
|
|
354
|
+
entries = sm.get_extension_list()
|
|
355
|
+
disabled_stems = {Path(e.path).stem for e in entries if not e.enabled}
|
|
356
|
+
entry_configs = {Path(e.path).stem: (e.settings or {}) for e in entries if e.enabled}
|
|
357
|
+
extra_entries = [e for e in entries if e.enabled]
|
|
358
|
+
runtime_ref = old.runtime_ref if old is not None else _RuntimeRef()
|
|
359
|
+
|
|
360
|
+
loader = ExtensionLoader(
|
|
361
|
+
project_dir=get_extensions_dir(cwd),
|
|
362
|
+
global_dir=get_extensions_dir(),
|
|
363
|
+
extra_entries=extra_entries,
|
|
364
|
+
disabled_stems=disabled_stems,
|
|
365
|
+
entry_configs=entry_configs,
|
|
366
|
+
llm=self._context.llm,
|
|
367
|
+
settings=sm,
|
|
368
|
+
cwd=cwd,
|
|
369
|
+
runtime_ref=runtime_ref,
|
|
370
|
+
events=EventBus(),
|
|
371
|
+
)
|
|
372
|
+
load_result = await loader.load()
|
|
373
|
+
new_ext = ExtensionRuntime(load_result, self._context.hooks, runtime_ref)
|
|
374
|
+
new_ext.runtime_ref.runtime = self
|
|
375
|
+
self._context.ext_runtime = new_ext
|
|
376
|
+
|
|
377
|
+
for cmd in new_ext.get_commands():
|
|
378
|
+
self.commands.register(cmd)
|
|
379
|
+
|
|
380
|
+
# ── Sync tools via registry then push to engine ───────────────────────
|
|
381
|
+
engine = self._context.engine
|
|
382
|
+
agent = self._context.agent
|
|
383
|
+
if engine is not None:
|
|
384
|
+
registry = self._context.tool_registry
|
|
385
|
+
registry.replace_source("extension", new_ext.get_tools())
|
|
386
|
+
registry.sync_to_engine(engine, layout=getattr(self, "_layout", None))
|
|
387
|
+
|
|
388
|
+
if agent is not None:
|
|
389
|
+
extra_appends = new_ext.get_prompt_appends()
|
|
390
|
+
skills = skill_registry.list()
|
|
391
|
+
agent._system_prompt = build_prompt(
|
|
392
|
+
PromptOptions(
|
|
393
|
+
cwd=cwd,
|
|
394
|
+
tools=registry.list(),
|
|
395
|
+
extra_appends=extra_appends,
|
|
396
|
+
skills=skills,
|
|
397
|
+
disable_context_files=self._config.disable_context_files,
|
|
398
|
+
project_trusted=self._config.project_trusted,
|
|
399
|
+
)
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
for ext in new_ext._extensions:
|
|
403
|
+
await self._emit_to_extension(ext, "extension_reloaded")
|
|
404
|
+
|
|
405
|
+
return load_result
|
|
406
|
+
|
|
407
|
+
async def reload_extension(self, ext_path: str):
|
|
408
|
+
"""Reload a single extension by its loaded module path, applying live.
|
|
409
|
+
|
|
410
|
+
Re-reads settings, re-runs only this extension's ``register`` with fresh
|
|
411
|
+
config, and swaps its tools/commands/prompt in place — other extensions
|
|
412
|
+
keep their existing state and are *not* re-run (so their resources and
|
|
413
|
+
side effects are untouched). Falls back to a full reload if the target
|
|
414
|
+
can't be resolved.
|
|
415
|
+
"""
|
|
416
|
+
from pathlib import Path
|
|
417
|
+
from tau.agent.prompt.builder import build_prompt
|
|
418
|
+
from tau.agent.prompt.types import PromptOptions
|
|
419
|
+
from tau.extensions.api import LoadExtensionsResult
|
|
420
|
+
from tau.extensions.events import EventBus
|
|
421
|
+
from tau.extensions.loader import ExtensionLoader
|
|
422
|
+
from tau.extensions.runtime import ExtensionRuntime
|
|
423
|
+
from tau.settings.paths import get_extensions_dir
|
|
424
|
+
from tau.skills.registry import skill_registry
|
|
425
|
+
|
|
426
|
+
sm = self._context.settings_manager
|
|
427
|
+
if sm is None:
|
|
428
|
+
return LoadExtensionsResult()
|
|
429
|
+
|
|
430
|
+
await sm.reload()
|
|
431
|
+
|
|
432
|
+
old = self._context.ext_runtime
|
|
433
|
+
if old is None:
|
|
434
|
+
return await self.reload_extensions()
|
|
435
|
+
target = next((e for e in old._extensions if e.path == ext_path), None)
|
|
436
|
+
if target is None:
|
|
437
|
+
# Unknown target — fall back to the all-extensions reload.
|
|
438
|
+
return await self.reload_extensions()
|
|
439
|
+
|
|
440
|
+
cwd = self._context.session_manager.cwd
|
|
441
|
+
entries = sm.get_extension_list()
|
|
442
|
+
entry_configs = {Path(e.path).stem: (e.settings or {}) for e in entries if e.enabled}
|
|
443
|
+
p = Path(ext_path)
|
|
444
|
+
stem = p.parent.name if p.name == "__init__.py" else p.stem
|
|
445
|
+
config = entry_configs.get(stem, {})
|
|
446
|
+
|
|
447
|
+
runtime_ref = old.runtime_ref
|
|
448
|
+
loader = ExtensionLoader(
|
|
449
|
+
project_dir=get_extensions_dir(cwd),
|
|
450
|
+
global_dir=get_extensions_dir(),
|
|
451
|
+
llm=self._context.llm,
|
|
452
|
+
settings=sm,
|
|
453
|
+
cwd=cwd,
|
|
454
|
+
runtime_ref=runtime_ref,
|
|
455
|
+
events=EventBus(),
|
|
456
|
+
)
|
|
457
|
+
# Populate the per-subdir caches (deps + manifest settings schema) for
|
|
458
|
+
# this extension so _load_one re-attaches its auto-generated panel.
|
|
459
|
+
loader._subdir_entries(p.parent)
|
|
460
|
+
new_ext, errs = await loader._load_one(p, config, source=target.source)
|
|
461
|
+
if new_ext is None:
|
|
462
|
+
# Keep the old extension on failure; surface load errors.
|
|
463
|
+
return LoadExtensionsResult(extensions=old._extensions, errors=errs)
|
|
464
|
+
|
|
465
|
+
# Let the outgoing extension release any resources it holds (subprocesses,
|
|
466
|
+
# background tasks, sockets) before it is replaced — reload does not do
|
|
467
|
+
# this automatically, so stateful extensions must handle `extension_unload`.
|
|
468
|
+
await self._emit_to_extension(target, "extension_unload")
|
|
469
|
+
|
|
470
|
+
new_list = [new_ext if e is target else e for e in old._extensions]
|
|
471
|
+
old.unsubscribe()
|
|
472
|
+
new_runtime = ExtensionRuntime(
|
|
473
|
+
LoadExtensionsResult(extensions=new_list, errors=errs),
|
|
474
|
+
self._context.hooks,
|
|
475
|
+
runtime_ref,
|
|
476
|
+
)
|
|
477
|
+
new_runtime.runtime_ref.runtime = self
|
|
478
|
+
self._context.ext_runtime = new_runtime
|
|
479
|
+
|
|
480
|
+
# ── Commands: drop the target's old commands, register its new set ────
|
|
481
|
+
for name in target.commands:
|
|
482
|
+
self.commands.unregister(name)
|
|
483
|
+
for cmd in new_ext.commands.values():
|
|
484
|
+
self.commands.register(cmd)
|
|
485
|
+
|
|
486
|
+
# ── Tools + prompt ────────────────────────────────────────────────────
|
|
487
|
+
engine = self._context.engine
|
|
488
|
+
agent = self._context.agent
|
|
489
|
+
if engine is not None:
|
|
490
|
+
registry = self._context.tool_registry
|
|
491
|
+
registry.replace_source("extension", new_runtime.get_tools())
|
|
492
|
+
registry.sync_to_engine(engine, layout=getattr(self, "_layout", None))
|
|
493
|
+
|
|
494
|
+
if agent is not None:
|
|
495
|
+
agent._system_prompt = build_prompt(
|
|
496
|
+
PromptOptions(
|
|
497
|
+
cwd=cwd,
|
|
498
|
+
tools=registry.list(),
|
|
499
|
+
extra_appends=new_runtime.get_prompt_appends(),
|
|
500
|
+
skills=skill_registry.list(),
|
|
501
|
+
disable_context_files=self._config.disable_context_files,
|
|
502
|
+
project_trusted=self._config.project_trusted,
|
|
503
|
+
)
|
|
504
|
+
)
|
|
505
|
+
|
|
506
|
+
# Give the freshly-loaded extension a chance to re-establish runtime state
|
|
507
|
+
# (e.g. warm up language servers) now that the runtime is already wired —
|
|
508
|
+
# `runtime_ready` only fires once at startup, not on reload.
|
|
509
|
+
await self._emit_to_extension(new_ext, "extension_reloaded")
|
|
510
|
+
|
|
511
|
+
return LoadExtensionsResult(extensions=new_list, errors=errs)
|
|
512
|
+
|
|
513
|
+
async def _emit_to_extension(self, ext, event_type: str) -> None:
|
|
514
|
+
"""Dispatch a lifecycle event directly to a single extension's handlers.
|
|
515
|
+
|
|
516
|
+
Used for reload-only events (``extension_unload`` / ``extension_reloaded``)
|
|
517
|
+
that must reach exactly one extension rather than every handler on the bus.
|
|
518
|
+
Handler exceptions are swallowed so one bad handler can't block the reload.
|
|
519
|
+
"""
|
|
520
|
+
import inspect
|
|
521
|
+
from types import SimpleNamespace
|
|
522
|
+
from tau.extensions.context import ExtensionContext
|
|
523
|
+
|
|
524
|
+
handlers = ext.handlers.get(event_type, [])
|
|
525
|
+
if not handlers:
|
|
526
|
+
return
|
|
527
|
+
ctx = ExtensionContext.from_runtime(self)
|
|
528
|
+
event = SimpleNamespace(type=event_type)
|
|
529
|
+
for handler in handlers:
|
|
530
|
+
try:
|
|
531
|
+
result = handler(event, ctx)
|
|
532
|
+
if inspect.isawaitable(result):
|
|
533
|
+
await result
|
|
534
|
+
except Exception:
|
|
535
|
+
# Don't let one failed handler abort the reload, but never fail
|
|
536
|
+
# silently — a botched dispose (e.g. servers not reaped) must be
|
|
537
|
+
# visible rather than leaking resources unnoticed.
|
|
538
|
+
import logging
|
|
539
|
+
logging.getLogger(__name__).exception(
|
|
540
|
+
"extension %s handler for %r raised", ext.path, event_type
|
|
541
|
+
)
|
|
542
|
+
|
|
543
|
+
# -------------------------------------------------------------------------
|
|
544
|
+
# Session lifecycle
|
|
545
|
+
# -------------------------------------------------------------------------
|
|
546
|
+
|
|
547
|
+
async def new_session(self, *, with_session=None) -> None:
|
|
548
|
+
"""Shut down the current session and start a fresh one."""
|
|
549
|
+
await self._emit_session_shutdown(SessionShutdownReason.New)
|
|
550
|
+
self._config = self._config.model_copy(update={"session_file": None})
|
|
551
|
+
self._context = await RuntimeContext.create(
|
|
552
|
+
self._config,
|
|
553
|
+
settings_manager=self.settings_manager,
|
|
554
|
+
hooks=self.hooks,
|
|
555
|
+
ext_runtime=self.extension_runtime,
|
|
556
|
+
)
|
|
557
|
+
self._reinit_after_context_create()
|
|
558
|
+
await self._run_with_session(with_session)
|
|
559
|
+
await self._emit_session_start(SessionStartReason.New)
|
|
560
|
+
|
|
561
|
+
async def resume_session(self, session_file: Path, *, with_session=None) -> None:
|
|
562
|
+
"""Shut down the current session and resume an existing one from a file."""
|
|
563
|
+
session_file = Path(session_file).resolve()
|
|
564
|
+
|
|
565
|
+
before_results = await self._context.hooks.emit(
|
|
566
|
+
SessionBeforeSwitchEvent(
|
|
567
|
+
reason=SessionBeforeSwitchReason.Resume, target_session_file=str(session_file)
|
|
568
|
+
)
|
|
569
|
+
)
|
|
570
|
+
for r in before_results:
|
|
571
|
+
if isinstance(r, SessionBeforeSwitchResult) and r.cancel:
|
|
572
|
+
return
|
|
573
|
+
|
|
574
|
+
await self._emit_session_shutdown(SessionShutdownReason.Resume)
|
|
575
|
+
self._config = self._config.model_copy(update={"session_file": session_file})
|
|
576
|
+
self._context = await RuntimeContext.create(
|
|
577
|
+
self._config,
|
|
578
|
+
settings_manager=self.settings_manager,
|
|
579
|
+
hooks=self.hooks,
|
|
580
|
+
ext_runtime=self.extension_runtime,
|
|
581
|
+
)
|
|
582
|
+
self._reinit_after_context_create()
|
|
583
|
+
await self._run_with_session(with_session)
|
|
584
|
+
await self._emit_session_start(SessionStartReason.Resume)
|
|
585
|
+
|
|
586
|
+
async def navigate_tree(
|
|
587
|
+
self,
|
|
588
|
+
target_id: str,
|
|
589
|
+
*,
|
|
590
|
+
summarize: bool = False,
|
|
591
|
+
custom_instructions: str | None = None,
|
|
592
|
+
replace_instructions: bool = False,
|
|
593
|
+
label: str | None = None,
|
|
594
|
+
) -> bool:
|
|
595
|
+
"""Navigate the session tree to target_id, optionally generating a branch summary.
|
|
596
|
+
|
|
597
|
+
Returns False if cancelled by an extension handler, True otherwise.
|
|
598
|
+
"""
|
|
599
|
+
sm = self._context.session_manager
|
|
600
|
+
if target_id not in sm.by_id:
|
|
601
|
+
raise KeyError(f"Entry '{target_id}' not found in session.")
|
|
602
|
+
|
|
603
|
+
old_leaf_id = sm.get_leaf_id()
|
|
604
|
+
if target_id == old_leaf_id:
|
|
605
|
+
return True # already there
|
|
606
|
+
|
|
607
|
+
# Collect entries between the old leaf and the common ancestor
|
|
608
|
+
from tau.session.branch_summarization import collect_entries_for_branch_summary
|
|
609
|
+
|
|
610
|
+
collect_result = collect_entries_for_branch_summary(sm, old_leaf_id, target_id)
|
|
611
|
+
entries_to_summarize = collect_result.entries
|
|
612
|
+
common_ancestor_id = collect_result.common_ancestor_id
|
|
613
|
+
|
|
614
|
+
# Build preparation for the before_tree hook
|
|
615
|
+
preparation = TreePreparation(
|
|
616
|
+
target_id=target_id,
|
|
617
|
+
old_leaf_id=old_leaf_id,
|
|
618
|
+
common_ancestor_id=common_ancestor_id,
|
|
619
|
+
custom_instructions=custom_instructions,
|
|
620
|
+
replace_instructions=replace_instructions,
|
|
621
|
+
label=label,
|
|
622
|
+
)
|
|
623
|
+
|
|
624
|
+
# Let extensions inspect / mutate / cancel
|
|
625
|
+
results = await self._context.hooks.emit(SessionBeforeTreeEvent(preparation=preparation))
|
|
626
|
+
for r in results:
|
|
627
|
+
if isinstance(r, SessionBeforeTreeResult):
|
|
628
|
+
if r.cancel:
|
|
629
|
+
return False
|
|
630
|
+
if r.custom_instructions is not None:
|
|
631
|
+
custom_instructions = r.custom_instructions
|
|
632
|
+
if r.replace_instructions is not None:
|
|
633
|
+
replace_instructions = r.replace_instructions
|
|
634
|
+
if r.label is not None:
|
|
635
|
+
label = r.label
|
|
636
|
+
|
|
637
|
+
# Generate branch summary if requested
|
|
638
|
+
if summarize and entries_to_summarize:
|
|
639
|
+
sm_settings = self._context.settings_manager
|
|
640
|
+
reserve_tokens = (
|
|
641
|
+
sm_settings.get_branch_summary_reserve_tokens()
|
|
642
|
+
if sm_settings is not None
|
|
643
|
+
else 16_384
|
|
644
|
+
)
|
|
645
|
+
from tau.session.branch_summarization import generate_branch_summary
|
|
646
|
+
|
|
647
|
+
llm = self._context.llm
|
|
648
|
+
result = await generate_branch_summary(
|
|
649
|
+
entries_to_summarize,
|
|
650
|
+
llm,
|
|
651
|
+
reserve_tokens=reserve_tokens,
|
|
652
|
+
custom_instructions=custom_instructions,
|
|
653
|
+
replace_instructions=replace_instructions,
|
|
654
|
+
)
|
|
655
|
+
if result.summary and not result.aborted:
|
|
656
|
+
sm.append_branch_summary(
|
|
657
|
+
from_id=old_leaf_id or "",
|
|
658
|
+
summary=result.summary,
|
|
659
|
+
label=label,
|
|
660
|
+
details={
|
|
661
|
+
"read_files": result.read_files,
|
|
662
|
+
"modified_files": result.modified_files,
|
|
663
|
+
},
|
|
664
|
+
)
|
|
665
|
+
|
|
666
|
+
sm.branch(target_id)
|
|
667
|
+
await self._context.hooks.emit(
|
|
668
|
+
SessionTreeEvent(new_leaf_id=target_id, old_leaf_id=old_leaf_id)
|
|
669
|
+
)
|
|
670
|
+
await self._emit_session_start(SessionStartReason.Fork)
|
|
671
|
+
return True
|
|
672
|
+
|
|
673
|
+
async def fork_session(
|
|
674
|
+
self,
|
|
675
|
+
from_entry_id: str,
|
|
676
|
+
*,
|
|
677
|
+
position: str = "at",
|
|
678
|
+
with_session=None,
|
|
679
|
+
) -> None:
|
|
680
|
+
"""Branch the session tree at the given entry and start a new leaf."""
|
|
681
|
+
sm = self._context.session_manager
|
|
682
|
+
if from_entry_id not in sm.by_id:
|
|
683
|
+
raise KeyError(f"Entry '{from_entry_id}' not found in session.")
|
|
684
|
+
|
|
685
|
+
before_results = await self._context.hooks.emit(
|
|
686
|
+
SessionBeforeForkEvent(entry_id=from_entry_id, position=position) # type: ignore[arg-type]
|
|
687
|
+
)
|
|
688
|
+
for r in before_results:
|
|
689
|
+
if isinstance(r, SessionBeforeForkResult) and r.cancel:
|
|
690
|
+
return
|
|
691
|
+
|
|
692
|
+
sm.branch(from_entry_id)
|
|
693
|
+
await self._run_with_session(with_session)
|
|
694
|
+
await self._emit_session_start(SessionStartReason.Fork)
|
|
695
|
+
|
|
696
|
+
async def clone_session(self) -> None:
|
|
697
|
+
"""Duplicate the current branch into a new session file and switch to it."""
|
|
698
|
+
sm = self._context.session_manager
|
|
699
|
+
leaf_id = sm.get_leaf_id()
|
|
700
|
+
if leaf_id is None:
|
|
701
|
+
raise ValueError("No active leaf to clone from.")
|
|
702
|
+
|
|
703
|
+
await self._emit_session_shutdown(SessionShutdownReason.Clone)
|
|
704
|
+
sm.create_branched_session(leaf_id)
|
|
705
|
+
self._reinit_after_context_create()
|
|
706
|
+
await self._emit_session_start(SessionStartReason.Clone)
|
|
707
|
+
|
|
708
|
+
def _reinit_after_context_create(self) -> None:
|
|
709
|
+
if self._context.agent is not None:
|
|
710
|
+
self._context.agent._runtime = self
|
|
711
|
+
# Keep the runtime ref pointing at this Runtime instance
|
|
712
|
+
if self._context.ext_runtime is not None:
|
|
713
|
+
self._context.ext_runtime.runtime_ref.runtime = self
|
|
714
|
+
|
|
715
|
+
async def _run_with_session(self, with_session) -> None:
|
|
716
|
+
"""Call the with_session(ctx) callback if provided, with a fresh context."""
|
|
717
|
+
if with_session is None:
|
|
718
|
+
return
|
|
719
|
+
import inspect
|
|
720
|
+
from tau.extensions.context import ExtensionContext
|
|
721
|
+
|
|
722
|
+
ctx = ExtensionContext.from_runtime(self)
|
|
723
|
+
try:
|
|
724
|
+
result = with_session(ctx)
|
|
725
|
+
if inspect.isawaitable(result):
|
|
726
|
+
await result
|
|
727
|
+
except Exception:
|
|
728
|
+
import logging
|
|
729
|
+
|
|
730
|
+
logging.getLogger(__name__).exception("with_session callback raised")
|
|
731
|
+
|
|
732
|
+
# -------------------------------------------------------------------------
|
|
733
|
+
# Shutdown
|
|
734
|
+
# -------------------------------------------------------------------------
|
|
735
|
+
|
|
736
|
+
def shutdown(self) -> None:
|
|
737
|
+
pass
|
|
738
|
+
|
|
739
|
+
async def ashutdown(self) -> None:
|
|
740
|
+
"""Tear down the runtime once the mode-specific loop has exited.
|
|
741
|
+
|
|
742
|
+
Emits `runtime_stop` (symmetric to the `runtime_ready` emitted in
|
|
743
|
+
`create`) so extensions can run terminal cleanup that must happen on quit
|
|
744
|
+
regardless of mode. Idempotent — guarded so a double call is a no-op.
|
|
745
|
+
"""
|
|
746
|
+
if self._stopped:
|
|
747
|
+
return
|
|
748
|
+
self._stopped = True
|
|
749
|
+
await self._context.hooks.emit(RuntimeStopEvent())
|
|
750
|
+
|
|
751
|
+
# -------------------------------------------------------------------------
|
|
752
|
+
# Event helpers
|
|
753
|
+
# -------------------------------------------------------------------------
|
|
754
|
+
|
|
755
|
+
async def _emit_session_start(self, reason: SessionStartReason) -> None:
|
|
756
|
+
await self._context.hooks.emit(SessionStartEvent(reason=reason))
|
|
757
|
+
|
|
758
|
+
async def _emit_session_shutdown(self, reason: SessionShutdownReason) -> None:
|
|
759
|
+
await self._context.hooks.emit(SessionShutdownEvent(reason=reason))
|