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/extensions/loader.py
ADDED
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import hashlib
|
|
5
|
+
import importlib.util
|
|
6
|
+
import inspect
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import sys
|
|
10
|
+
import traceback
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import TYPE_CHECKING, Any
|
|
13
|
+
|
|
14
|
+
from tau.extensions.api import (
|
|
15
|
+
Extension, ExtensionAPI, ExtensionError,
|
|
16
|
+
LoadExtensionsResult, _RuntimeRef,
|
|
17
|
+
)
|
|
18
|
+
from tau.extensions.events import EventBus
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from tau.inference.api.text.service import TextLLM
|
|
22
|
+
from tau.settings.manager import SettingsManager
|
|
23
|
+
from tau.settings.types import ExtensionEntry
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
_ENTRY_POINT = "register"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ExtensionLoader:
|
|
31
|
+
"""
|
|
32
|
+
Discovers and loads Python extension files from configured directories.
|
|
33
|
+
|
|
34
|
+
Discovery order (project first, then global, then explicit paths):
|
|
35
|
+
- <cwd>/.tau/extensions/
|
|
36
|
+
- ~/.tau/extensions/
|
|
37
|
+
- Any entries in settings.extensions.list that are enabled
|
|
38
|
+
|
|
39
|
+
Within each directory, files and subdirectories are sorted alphabetically.
|
|
40
|
+
Files whose names start with ``_`` are skipped. Files whose stem appears
|
|
41
|
+
in ``disabled_stems`` are also skipped. Duplicate resolved paths are
|
|
42
|
+
skipped silently. A failing extension is logged and recorded in the
|
|
43
|
+
returned ``LoadExtensionsResult`` — it never crashes startup.
|
|
44
|
+
|
|
45
|
+
Subdirectory support:
|
|
46
|
+
Given ``extensions/my_pkg/``:
|
|
47
|
+
1. ``my_pkg/manifest.json`` with ``{"tau": {"extensions": ["./main.py"]}}``
|
|
48
|
+
→ load the declared paths.
|
|
49
|
+
2. ``my_pkg/__init__.py``
|
|
50
|
+
→ load as a Python package entry point.
|
|
51
|
+
If neither is present the subdirectory is ignored.
|
|
52
|
+
|
|
53
|
+
Dependency installation:
|
|
54
|
+
``manifest.json`` may also declare ``{"tau": {"dependencies": ["pkg>=1.0"]}}``.
|
|
55
|
+
Before the entry file is executed, these specs are installed (once — cached by
|
|
56
|
+
a hash of the dependency list) via ``uv pip install``, falling back to the
|
|
57
|
+
venv's own ``pip`` when ``uv`` is not on PATH. No dedicated venv is created
|
|
58
|
+
under ``.tau/``: project extensions install into the project's own ``.venv``
|
|
59
|
+
when one already exists, otherwise everything falls back to the global
|
|
60
|
+
packages venv at ``~/.tau/venv``. The resolved venv's site-packages directory
|
|
61
|
+
is then added to ``sys.path`` so the extension's own imports resolve.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
def __init__(
|
|
65
|
+
self,
|
|
66
|
+
project_dir: Path | None = None,
|
|
67
|
+
global_dir: Path | None = None,
|
|
68
|
+
extra_entries: list[ExtensionEntry] | None = None,
|
|
69
|
+
extra_sources: dict[str, str] | None = None,
|
|
70
|
+
disabled_stems: set[str] | None = None,
|
|
71
|
+
entry_configs: dict[str, dict] | None = None,
|
|
72
|
+
builtins_dir: Path | None = None,
|
|
73
|
+
# Dependencies injected for per-extension ExtensionAPI creation
|
|
74
|
+
llm: TextLLM | None = None,
|
|
75
|
+
settings: SettingsManager | None = None,
|
|
76
|
+
cwd: Path | None = None,
|
|
77
|
+
runtime_ref: _RuntimeRef | None = None,
|
|
78
|
+
events: EventBus | None = None,
|
|
79
|
+
) -> None:
|
|
80
|
+
self._builtins_dir: Path | None = builtins_dir if (builtins_dir is not None and builtins_dir.is_dir()) else None
|
|
81
|
+
self._dirs: list[Path] = []
|
|
82
|
+
self._dir_sources: dict[Path, str] = {}
|
|
83
|
+
self._extra_paths: list[Path] = []
|
|
84
|
+
self._extra_sources: dict[str, str] = extra_sources or {}
|
|
85
|
+
self._disabled_stems: set[str] = disabled_stems or set()
|
|
86
|
+
self._entry_configs: dict[str, dict] = entry_configs or {}
|
|
87
|
+
self._subdir_deps: dict[Path, list[str]] = {}
|
|
88
|
+
self._subdir_settings: dict[Path, dict] = {}
|
|
89
|
+
self._llm = llm
|
|
90
|
+
self._settings = settings
|
|
91
|
+
self._cwd = cwd or Path(".")
|
|
92
|
+
self._runtime_ref = runtime_ref
|
|
93
|
+
self._events = events or EventBus()
|
|
94
|
+
|
|
95
|
+
# Project first, then global
|
|
96
|
+
if project_dir is not None and project_dir.is_dir():
|
|
97
|
+
self._dirs.append(project_dir)
|
|
98
|
+
self._dir_sources[project_dir] = "project"
|
|
99
|
+
if global_dir is not None and global_dir.is_dir():
|
|
100
|
+
self._dirs.append(global_dir)
|
|
101
|
+
self._dir_sources[global_dir] = "global"
|
|
102
|
+
|
|
103
|
+
for entry in (extra_entries or []):
|
|
104
|
+
resolved = Path(entry.path).expanduser().resolve()
|
|
105
|
+
if resolved.exists():
|
|
106
|
+
self._extra_paths.append(resolved)
|
|
107
|
+
if entry.settings:
|
|
108
|
+
self._entry_configs.setdefault(resolved.stem, entry.settings)
|
|
109
|
+
|
|
110
|
+
# ── Discovery ──────────────────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
def _subdir_entries(self, subdir: Path) -> list[Path]:
|
|
113
|
+
"""Return extension entry files for a subdirectory, or empty list."""
|
|
114
|
+
manifest = subdir / "manifest.json"
|
|
115
|
+
if manifest.is_file():
|
|
116
|
+
try:
|
|
117
|
+
data = json.loads(manifest.read_text(encoding="utf-8"))
|
|
118
|
+
from tau.settings.paths import get_app_name
|
|
119
|
+
app_data = data.get(get_app_name().lower(), {})
|
|
120
|
+
|
|
121
|
+
deps = app_data.get("dependencies", [])
|
|
122
|
+
if deps:
|
|
123
|
+
self._subdir_deps[subdir.resolve()] = list(deps)
|
|
124
|
+
|
|
125
|
+
settings_schema = app_data.get("settings")
|
|
126
|
+
if isinstance(settings_schema, dict):
|
|
127
|
+
self._subdir_settings[subdir.resolve()] = settings_schema
|
|
128
|
+
|
|
129
|
+
declared = app_data.get("extensions", [])
|
|
130
|
+
if declared:
|
|
131
|
+
entries = [(subdir / p).resolve() for p in declared]
|
|
132
|
+
valid = [e for e in entries if e.is_file()]
|
|
133
|
+
if valid:
|
|
134
|
+
return valid
|
|
135
|
+
except (json.JSONDecodeError, OSError):
|
|
136
|
+
pass
|
|
137
|
+
|
|
138
|
+
init = subdir / "__init__.py"
|
|
139
|
+
if init.is_file():
|
|
140
|
+
return [init]
|
|
141
|
+
|
|
142
|
+
return []
|
|
143
|
+
|
|
144
|
+
def _discover_in_dir(self, d: Path, seen: set[Path]) -> list[Path]:
|
|
145
|
+
"""Collect extension files from one directory (one level deep)."""
|
|
146
|
+
found: list[Path] = []
|
|
147
|
+
for entry in sorted(d.iterdir(), key=lambda e: e.name):
|
|
148
|
+
if entry.name.startswith("_"):
|
|
149
|
+
continue
|
|
150
|
+
if entry.is_file() and entry.suffix == ".py":
|
|
151
|
+
if entry.stem in self._disabled_stems:
|
|
152
|
+
continue
|
|
153
|
+
r = entry.resolve()
|
|
154
|
+
if r not in seen:
|
|
155
|
+
seen.add(r)
|
|
156
|
+
found.append(entry)
|
|
157
|
+
elif entry.is_dir():
|
|
158
|
+
if entry.name in self._disabled_stems:
|
|
159
|
+
continue
|
|
160
|
+
for sub_entry in self._subdir_entries(entry):
|
|
161
|
+
r = sub_entry.resolve()
|
|
162
|
+
if r not in seen:
|
|
163
|
+
seen.add(r)
|
|
164
|
+
found.append(sub_entry)
|
|
165
|
+
return found
|
|
166
|
+
|
|
167
|
+
def _discover(self) -> list[tuple[Path, str]]:
|
|
168
|
+
"""Return deduplicated list of (extension file, source label) pairs to load."""
|
|
169
|
+
found: list[tuple[Path, str]] = []
|
|
170
|
+
seen: set[Path] = set()
|
|
171
|
+
|
|
172
|
+
# Builtins always first, before any user dirs
|
|
173
|
+
if self._builtins_dir is not None:
|
|
174
|
+
for path in self._discover_in_dir(self._builtins_dir, seen):
|
|
175
|
+
found.append((path, "builtin"))
|
|
176
|
+
|
|
177
|
+
for d in self._dirs:
|
|
178
|
+
source = self._dir_sources.get(d, "unknown")
|
|
179
|
+
for path in self._discover_in_dir(d, seen):
|
|
180
|
+
found.append((path, source))
|
|
181
|
+
|
|
182
|
+
for p in self._extra_paths:
|
|
183
|
+
source = self._extra_sources.get(str(p.resolve()), "explicit")
|
|
184
|
+
if p.is_file() and p.suffix == ".py":
|
|
185
|
+
r = p.resolve()
|
|
186
|
+
if r not in seen:
|
|
187
|
+
seen.add(r)
|
|
188
|
+
found.append((p, source))
|
|
189
|
+
elif p.is_dir():
|
|
190
|
+
entries = self._subdir_entries(p)
|
|
191
|
+
if entries:
|
|
192
|
+
for e in entries:
|
|
193
|
+
r = e.resolve()
|
|
194
|
+
if r not in seen:
|
|
195
|
+
seen.add(r)
|
|
196
|
+
found.append((e, source))
|
|
197
|
+
else:
|
|
198
|
+
for path in self._discover_in_dir(p, seen):
|
|
199
|
+
found.append((path, source))
|
|
200
|
+
|
|
201
|
+
return found
|
|
202
|
+
|
|
203
|
+
# ── Dependency installation ──────────────────────────────────────────────────
|
|
204
|
+
|
|
205
|
+
def _resolve_venv_dir(self, source: str) -> Path:
|
|
206
|
+
"""Pick a venv to install extension dependencies into.
|
|
207
|
+
|
|
208
|
+
Project extensions always use the project's own ``.venv`` — reusing it
|
|
209
|
+
if it already exists, creating it at ``<cwd>/.venv`` if not. No dedicated
|
|
210
|
+
venv is ever created under ``.tau/``. Everything else (global, explicit,
|
|
211
|
+
unknown sources) uses the global packages venv at ``~/.tau/venv``.
|
|
212
|
+
"""
|
|
213
|
+
from tau.settings.paths import get_packages_venv
|
|
214
|
+
|
|
215
|
+
if source == "project":
|
|
216
|
+
return self._cwd / ".venv"
|
|
217
|
+
|
|
218
|
+
return get_packages_venv(None)
|
|
219
|
+
|
|
220
|
+
def _ensure_dependencies(self, subdir: Path, deps: list[str], source: str) -> None:
|
|
221
|
+
"""Install an extension's declared dependencies, once per dependency set.
|
|
222
|
+
|
|
223
|
+
Runs synchronously (called via ``asyncio.to_thread``) so the blocking
|
|
224
|
+
subprocess install doesn't stall the event loop.
|
|
225
|
+
"""
|
|
226
|
+
from tau.packages.manager import PackageManager
|
|
227
|
+
|
|
228
|
+
venv_dir = self._resolve_venv_dir(source)
|
|
229
|
+
pkg_mgr = PackageManager(venv_dir)
|
|
230
|
+
|
|
231
|
+
digest = hashlib.sha256("\n".join(sorted(deps)).encode("utf-8")).hexdigest()
|
|
232
|
+
cache_file = venv_dir / ".tau_ext_deps.json"
|
|
233
|
+
cache: dict[str, str] = {}
|
|
234
|
+
if cache_file.is_file():
|
|
235
|
+
try:
|
|
236
|
+
cache = json.loads(cache_file.read_text(encoding="utf-8"))
|
|
237
|
+
except (json.JSONDecodeError, OSError):
|
|
238
|
+
cache = {}
|
|
239
|
+
|
|
240
|
+
key = str(subdir.resolve())
|
|
241
|
+
if cache.get(key) != digest:
|
|
242
|
+
logger.info("Installing dependencies for extension %r: %s", subdir.name, ", ".join(deps))
|
|
243
|
+
pkg_mgr.install_requirements(deps)
|
|
244
|
+
cache[key] = digest
|
|
245
|
+
cache_file.parent.mkdir(parents=True, exist_ok=True)
|
|
246
|
+
cache_file.write_text(json.dumps(cache, indent=2), encoding="utf-8")
|
|
247
|
+
|
|
248
|
+
site = pkg_mgr.site_packages()
|
|
249
|
+
if site and str(site) not in sys.path:
|
|
250
|
+
sys.path.insert(0, str(site))
|
|
251
|
+
|
|
252
|
+
# ── Loading ────────────────────────────────────────────────────────────────
|
|
253
|
+
|
|
254
|
+
async def load(self) -> LoadExtensionsResult:
|
|
255
|
+
"""Discover and load all extensions in parallel.
|
|
256
|
+
|
|
257
|
+
Returns a ``LoadExtensionsResult`` with loaded ``Extension`` objects and
|
|
258
|
+
any ``ExtensionError`` entries for files that failed to load.
|
|
259
|
+
"""
|
|
260
|
+
discovered = self._discover()
|
|
261
|
+
|
|
262
|
+
async def _load(path: Path, source: str) -> tuple[Extension | None, list[ExtensionError]]:
|
|
263
|
+
config_key = path.parent.name if path.name == "__init__.py" else path.stem
|
|
264
|
+
config = self._entry_configs.get(config_key, {})
|
|
265
|
+
ext, errs = await self._load_one(path, config, source=source)
|
|
266
|
+
if ext is not None:
|
|
267
|
+
logger.debug("Loaded extension: %s (%s)", path, source)
|
|
268
|
+
return ext, errs
|
|
269
|
+
|
|
270
|
+
results = await asyncio.gather(*[_load(p, s) for p, s in discovered])
|
|
271
|
+
|
|
272
|
+
extensions: list[Extension] = []
|
|
273
|
+
errors: list[ExtensionError] = []
|
|
274
|
+
for ext, errs in results:
|
|
275
|
+
if ext is not None:
|
|
276
|
+
extensions.append(ext)
|
|
277
|
+
errors.extend(errs)
|
|
278
|
+
|
|
279
|
+
return LoadExtensionsResult(extensions=extensions, errors=errors)
|
|
280
|
+
|
|
281
|
+
async def _load_one(
|
|
282
|
+
self, path: Path, config: dict, *, source: str = "unknown"
|
|
283
|
+
) -> tuple[Extension | None, list[ExtensionError]]:
|
|
284
|
+
"""Load a single extension file and call its ``register(tau)`` factory."""
|
|
285
|
+
str_path = str(path)
|
|
286
|
+
errors: list[ExtensionError] = []
|
|
287
|
+
|
|
288
|
+
try:
|
|
289
|
+
deps = self._subdir_deps.get(path.parent.resolve())
|
|
290
|
+
if deps and source != "builtin":
|
|
291
|
+
await asyncio.to_thread(self._ensure_dependencies, path.parent, deps, source)
|
|
292
|
+
|
|
293
|
+
module_name = f"_tau_ext_{hashlib.sha1(str(path.resolve()).encode()).hexdigest()[:16]}"
|
|
294
|
+
spec = importlib.util.spec_from_file_location(module_name, path)
|
|
295
|
+
if spec is None or spec.loader is None:
|
|
296
|
+
errors.append(ExtensionError(
|
|
297
|
+
extension_path=str_path, event="load",
|
|
298
|
+
error=f"Cannot create module spec for {path}",
|
|
299
|
+
))
|
|
300
|
+
return None, errors
|
|
301
|
+
|
|
302
|
+
module = importlib.util.module_from_spec(spec)
|
|
303
|
+
sys.modules[module_name] = module
|
|
304
|
+
spec.loader.exec_module(module) # type: ignore[union-attr]
|
|
305
|
+
|
|
306
|
+
register_fn = getattr(module, _ENTRY_POINT, None)
|
|
307
|
+
if register_fn is None or not callable(register_fn):
|
|
308
|
+
errors.append(ExtensionError(
|
|
309
|
+
extension_path=str_path, event="load",
|
|
310
|
+
error=f"No '{_ENTRY_POINT}(tau)' function in {path.name}",
|
|
311
|
+
))
|
|
312
|
+
return None, errors
|
|
313
|
+
|
|
314
|
+
ext = Extension(path=str_path, config=config, source=source)
|
|
315
|
+
api = ExtensionAPI(
|
|
316
|
+
extension=ext,
|
|
317
|
+
llm=self._llm, # type: ignore[arg-type]
|
|
318
|
+
settings=self._settings, # type: ignore[arg-type]
|
|
319
|
+
cwd=self._cwd,
|
|
320
|
+
runtime_ref=self._runtime_ref,
|
|
321
|
+
events=self._events,
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
result = register_fn(api)
|
|
325
|
+
if inspect.isawaitable(result):
|
|
326
|
+
await result
|
|
327
|
+
|
|
328
|
+
self._attach_manifest_panel(ext, path)
|
|
329
|
+
|
|
330
|
+
return ext, errors
|
|
331
|
+
|
|
332
|
+
except Exception:
|
|
333
|
+
tb = traceback.format_exc()
|
|
334
|
+
errors.append(ExtensionError(
|
|
335
|
+
extension_path=str_path, event="load",
|
|
336
|
+
error=tb.strip().splitlines()[-1],
|
|
337
|
+
stack=tb,
|
|
338
|
+
))
|
|
339
|
+
return None, errors
|
|
340
|
+
|
|
341
|
+
def _attach_manifest_panel(self, ext: Extension, path: Path) -> None:
|
|
342
|
+
"""Auto-build a /settings sub-panel from the manifest ``settings`` schema.
|
|
343
|
+
|
|
344
|
+
Skipped when the extension already registered a panel itself (manual
|
|
345
|
+
``register_settings`` wins) or when no schema is declared.
|
|
346
|
+
"""
|
|
347
|
+
if ext.settings_registrations:
|
|
348
|
+
return
|
|
349
|
+
if self._settings is None:
|
|
350
|
+
return # no settings manager → nothing to persist changes to
|
|
351
|
+
settings = self._settings
|
|
352
|
+
schema = self._subdir_settings.get(path.parent.resolve())
|
|
353
|
+
if not schema:
|
|
354
|
+
return
|
|
355
|
+
|
|
356
|
+
from tau.tui.components.settings_schema import build_manifest_panel
|
|
357
|
+
|
|
358
|
+
ext_dir = path.parent
|
|
359
|
+
try:
|
|
360
|
+
settings_path = str(ext_dir.relative_to(self._cwd))
|
|
361
|
+
except ValueError:
|
|
362
|
+
settings_path = str(ext_dir)
|
|
363
|
+
module_path = str(path) # absolute path of the loaded entry file
|
|
364
|
+
|
|
365
|
+
def _apply(key: str, value: Any) -> None:
|
|
366
|
+
# Persist to settings.json, then reload just this extension so the
|
|
367
|
+
# change takes effect live without re-running other extensions.
|
|
368
|
+
settings.set_extension_config_key(settings_path, key, value)
|
|
369
|
+
runtime = self._runtime_ref.runtime if self._runtime_ref is not None else None
|
|
370
|
+
if runtime is None:
|
|
371
|
+
return
|
|
372
|
+
reload_one = getattr(runtime, "reload_extension", None)
|
|
373
|
+
if reload_one is not None:
|
|
374
|
+
asyncio.ensure_future(reload_one(module_path))
|
|
375
|
+
else: # fall back to full reload if single-extension reload is unavailable
|
|
376
|
+
reload_all = getattr(runtime, "reload_extensions", None)
|
|
377
|
+
if reload_all is not None:
|
|
378
|
+
asyncio.ensure_future(reload_all())
|
|
379
|
+
|
|
380
|
+
reg = build_manifest_panel(
|
|
381
|
+
schema, ext.config,
|
|
382
|
+
default_title=ext_dir.name,
|
|
383
|
+
apply=_apply,
|
|
384
|
+
)
|
|
385
|
+
if reg is not None:
|
|
386
|
+
ext.settings_registrations.append(reg)
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
import traceback
|
|
5
|
+
from typing import TYPE_CHECKING, Any, Callable
|
|
6
|
+
|
|
7
|
+
from tau.extensions.api import (
|
|
8
|
+
Extension, ExtensionError, LoadExtensionsResult,
|
|
9
|
+
ShortcutRegistration, _RuntimeRef,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from tau.commands.types import CommandInfo
|
|
14
|
+
from tau.hooks.service import Hooks
|
|
15
|
+
from tau.tool.types import Tool
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# Events where handler return values matter for interception.
|
|
19
|
+
# These are registered directly on the hooks bus (not via the catch-all subscriber)
|
|
20
|
+
# so that Hooks.emit() collects their results.
|
|
21
|
+
_INTERCEPTABLE_EVENTS: frozenset[str] = frozenset({
|
|
22
|
+
"before_compaction",
|
|
23
|
+
"user_terminal",
|
|
24
|
+
"resources_discover",
|
|
25
|
+
"project_trust",
|
|
26
|
+
"input",
|
|
27
|
+
"tool_result",
|
|
28
|
+
"context",
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ExtensionRuntime:
|
|
33
|
+
"""
|
|
34
|
+
Owns all loaded extensions and dispatches lifecycle events to their handlers.
|
|
35
|
+
|
|
36
|
+
Subscribed as a catch-all listener on the hooks bus so that every event
|
|
37
|
+
emitted by the agent or runtime automatically reaches extension handlers —
|
|
38
|
+
no changes to the agent or engine are required.
|
|
39
|
+
|
|
40
|
+
Each handler is always called as ``handler(event, ctx)`` where ``ctx`` is a
|
|
41
|
+
fresh ``ExtensionContext`` snapshot built from the live runtime at dispatch
|
|
42
|
+
time. Handler exceptions are caught per-handler and appended to ``errors``.
|
|
43
|
+
|
|
44
|
+
Interceptable events (``_INTERCEPTABLE_EVENTS``) are registered directly on
|
|
45
|
+
the hooks bus rather than going through the catch-all subscriber, so their
|
|
46
|
+
return values are collected by ``Hooks.emit()`` and available for inspection
|
|
47
|
+
by the caller (e.g. ``before_compaction`` handlers that return
|
|
48
|
+
``BeforeCompactionResult``).
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
load_result: LoadExtensionsResult,
|
|
54
|
+
hooks: Hooks,
|
|
55
|
+
runtime_ref: _RuntimeRef,
|
|
56
|
+
) -> None:
|
|
57
|
+
self._extensions: list[Extension] = load_result.extensions
|
|
58
|
+
self._errors: list[ExtensionError] = list(load_result.errors)
|
|
59
|
+
self.runtime_ref: _RuntimeRef = runtime_ref
|
|
60
|
+
self._unsub = hooks.subscribe(self._dispatch)
|
|
61
|
+
|
|
62
|
+
# Register interceptable handlers directly so their results flow back
|
|
63
|
+
# through Hooks.emit() rather than being discarded by the subscriber path.
|
|
64
|
+
self._interceptable_unsubs: list[Callable[[], None]] = []
|
|
65
|
+
for ext in self._extensions:
|
|
66
|
+
for event_type in _INTERCEPTABLE_EVENTS:
|
|
67
|
+
for handler in ext.handlers.get(event_type, []):
|
|
68
|
+
wrapped = self._make_interceptable_handler(ext, handler)
|
|
69
|
+
self._interceptable_unsubs.append(hooks.register(event_type, wrapped))
|
|
70
|
+
|
|
71
|
+
# ── Dispatch ──────────────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
def _make_interceptable_handler(self, ext: Extension, handler: Callable) -> Callable:
|
|
74
|
+
"""Return a hooks-compatible wrapper that injects ctx and propagates the return value."""
|
|
75
|
+
async def wrapped(event: Any) -> Any:
|
|
76
|
+
"""Invoke handler with extension context."""
|
|
77
|
+
from tau.extensions.context import ExtensionContext
|
|
78
|
+
runtime = self.runtime_ref.runtime
|
|
79
|
+
ctx = ExtensionContext.from_runtime(runtime) if runtime is not None else None
|
|
80
|
+
try:
|
|
81
|
+
result = handler(event, ctx)
|
|
82
|
+
if inspect.isawaitable(result):
|
|
83
|
+
result = await result
|
|
84
|
+
return result
|
|
85
|
+
except Exception:
|
|
86
|
+
tb = traceback.format_exc()
|
|
87
|
+
self._errors.append(ExtensionError(
|
|
88
|
+
extension_path=ext.path,
|
|
89
|
+
event=getattr(event, "type", "unknown"),
|
|
90
|
+
error=tb.strip().splitlines()[-1],
|
|
91
|
+
stack=tb,
|
|
92
|
+
))
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
return wrapped
|
|
96
|
+
|
|
97
|
+
async def _dispatch(self, event: Any) -> None:
|
|
98
|
+
"""Catch-all hooks subscriber — re-dispatches every event to extension handlers.
|
|
99
|
+
|
|
100
|
+
Interceptable events are skipped here; they are already handled by
|
|
101
|
+
directly-registered hooks so their return values reach Hooks.emit().
|
|
102
|
+
"""
|
|
103
|
+
event_type: str | None = getattr(event, "type", None)
|
|
104
|
+
if not event_type:
|
|
105
|
+
return
|
|
106
|
+
if event_type in _INTERCEPTABLE_EVENTS:
|
|
107
|
+
return
|
|
108
|
+
|
|
109
|
+
from tau.extensions.context import ExtensionContext
|
|
110
|
+
runtime = self.runtime_ref.runtime
|
|
111
|
+
ctx = ExtensionContext.from_runtime(runtime) if runtime is not None else None
|
|
112
|
+
|
|
113
|
+
for ext in self._extensions:
|
|
114
|
+
for handler in ext.handlers.get(event_type, []):
|
|
115
|
+
try:
|
|
116
|
+
result = handler(event, ctx)
|
|
117
|
+
if inspect.isawaitable(result):
|
|
118
|
+
await result
|
|
119
|
+
except Exception:
|
|
120
|
+
tb = traceback.format_exc()
|
|
121
|
+
self._errors.append(ExtensionError(
|
|
122
|
+
extension_path=ext.path,
|
|
123
|
+
event=event_type,
|
|
124
|
+
error=tb.strip().splitlines()[-1],
|
|
125
|
+
stack=tb,
|
|
126
|
+
))
|
|
127
|
+
|
|
128
|
+
def unsubscribe(self) -> None:
|
|
129
|
+
"""Detach from the hooks bus (called before hot-reload replaces this runtime)."""
|
|
130
|
+
self._unsub()
|
|
131
|
+
for unsub in self._interceptable_unsubs:
|
|
132
|
+
unsub()
|
|
133
|
+
self._interceptable_unsubs.clear()
|
|
134
|
+
|
|
135
|
+
# ── Errors ────────────────────────────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
@property
|
|
138
|
+
def errors(self) -> list[ExtensionError]:
|
|
139
|
+
"""All accumulated load and dispatch errors."""
|
|
140
|
+
return self._errors
|
|
141
|
+
|
|
142
|
+
# ── Accessors ─────────────────────────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
def get_tools(self) -> list[Tool]:
|
|
145
|
+
"""Return all tools registered by extensions (last-writer-wins on name)."""
|
|
146
|
+
tools: dict[str, Any] = {}
|
|
147
|
+
for ext in self._extensions:
|
|
148
|
+
tools.update(ext.tools)
|
|
149
|
+
return list(tools.values())
|
|
150
|
+
|
|
151
|
+
def get_commands(self) -> list[CommandInfo]:
|
|
152
|
+
"""Return all slash commands registered by extensions (last-writer-wins on name)."""
|
|
153
|
+
commands: dict[str, Any] = {}
|
|
154
|
+
for ext in self._extensions:
|
|
155
|
+
commands.update(ext.commands)
|
|
156
|
+
return list(commands.values())
|
|
157
|
+
|
|
158
|
+
def get_shortcuts(self) -> list[ShortcutRegistration]:
|
|
159
|
+
"""Return all keyboard shortcuts registered by extensions."""
|
|
160
|
+
result: list[ShortcutRegistration] = []
|
|
161
|
+
for ext in self._extensions:
|
|
162
|
+
result.extend(ext.shortcuts)
|
|
163
|
+
return result
|
|
164
|
+
|
|
165
|
+
def get_prompt_appends(self) -> list[str]:
|
|
166
|
+
"""Return all system-prompt additions registered by extensions."""
|
|
167
|
+
result: list[str] = []
|
|
168
|
+
for ext in self._extensions:
|
|
169
|
+
result.extend(ext.prompt_appends)
|
|
170
|
+
return result
|
|
171
|
+
|
|
172
|
+
def get_message_renderers(self) -> dict[str, Any]:
|
|
173
|
+
"""Return merged message renderer registry (last-registered wins per type)."""
|
|
174
|
+
result: dict[str, Any] = {}
|
|
175
|
+
for ext in self._extensions:
|
|
176
|
+
result.update(ext.message_renderers)
|
|
177
|
+
return result
|
|
178
|
+
|
|
179
|
+
def get_autocomplete_providers(self) -> list[Any]:
|
|
180
|
+
"""Return all autocomplete providers registered by extensions."""
|
|
181
|
+
result: list[Any] = []
|
|
182
|
+
for ext in self._extensions:
|
|
183
|
+
result.extend(ext.autocomplete_providers)
|
|
184
|
+
return result
|