From 4f522bef8001a9913665af2252afed5bdf4222f6 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Tue, 12 May 2026 14:47:03 -0700 Subject: [PATCH 01/73] [tau] Start on the coding agent demo --- examples/.test_scripts/check-examples.py | 6 + examples/tau-agent/.gitignore | 1 + examples/tau-agent/README.md | 27 + examples/tau-agent/pyproject.toml | 12 + examples/tau-agent/tau.py | 255 ++++++++ examples/tau-agent/uv.lock | 765 +++++++++++++++++++++++ 6 files changed, 1066 insertions(+) create mode 100644 examples/tau-agent/.gitignore create mode 100644 examples/tau-agent/README.md create mode 100644 examples/tau-agent/pyproject.toml create mode 100644 examples/tau-agent/tau.py create mode 100644 examples/tau-agent/uv.lock diff --git a/examples/.test_scripts/check-examples.py b/examples/.test_scripts/check-examples.py index 1e7e4d2c..f9b1182f 100755 --- a/examples/.test_scripts/check-examples.py +++ b/examples/.test_scripts/check-examples.py @@ -30,6 +30,12 @@ ["fastapi", "textual", "websockets"], ["."], ), + ( + "tau-agent", + REPO / "examples" / "tau-agent", + ["textual"], + ["."], + ), ( "temporal-direct", REPO / "examples" / "temporal-direct", diff --git a/examples/tau-agent/.gitignore b/examples/tau-agent/.gitignore new file mode 100644 index 00000000..d6a0ef4e --- /dev/null +++ b/examples/tau-agent/.gitignore @@ -0,0 +1 @@ +.tau/ diff --git a/examples/tau-agent/README.md b/examples/tau-agent/README.md new file mode 100644 index 00000000..2a2cb358 --- /dev/null +++ b/examples/tau-agent/README.md @@ -0,0 +1,27 @@ +# tau-agent + +`tau` is a coding-agent demo built on the `ai` library. This is the +chat-bot baseline — single process, Textual TUI, streaming replies, no +tools yet. Future iterations will grow real coding capabilities on +top. + +## Setup + +```bash +uv sync +``` + +## Running + +```bash +uv run python tau.py +``` + +Type a message, hit enter. `ctrl+c` to quit. + +## Environment + +| Variable | Description | Default | +|----------|-------------|---------| +| `AI_GATEWAY_API_KEY` | Vercel AI Gateway API key | — | +| `TAU_MODEL` | Model id passed to `ai.ai_gateway(...)` | `anthropic/claude-sonnet-4.5` | diff --git a/examples/tau-agent/pyproject.toml b/examples/tau-agent/pyproject.toml new file mode 100644 index 00000000..7e749f7f --- /dev/null +++ b/examples/tau-agent/pyproject.toml @@ -0,0 +1,12 @@ +[project] +name = "tau-agent" +version = "0.1.0" +description = "Tau — a coding-agent chat bot demo built with the ai library and Textual" +requires-python = ">=3.12" +dependencies = [ + "ai", + "textual>=3.0", +] + +[tool.uv.sources] +ai = { path = "../..", editable = true } diff --git a/examples/tau-agent/tau.py b/examples/tau-agent/tau.py new file mode 100644 index 00000000..cab546a4 --- /dev/null +++ b/examples/tau-agent/tau.py @@ -0,0 +1,255 @@ +"""tau — a coding-agent chat bot built on the `ai` library and Textual. + +Single-process Textual TUI. The user types a message, it gets appended +to a running conversation history, and the agent streams its reply into +a new assistant bubble. No tools yet — this is the chat-bot baseline +we'll grow real coding capabilities on top of. + + uv run python tau.py +""" + +from __future__ import annotations + +import os + +import rich.text +import textual +import textual.app +import textual.binding +import textual.containers +import textual.events +import textual.message +import textual.widgets +import textual.worker + +import ai + +_raw_model = os.environ.get("TAU_MODEL", "gateway:anthropic/claude-opus-4.6") +MODEL_ID = _raw_model if ":" in _raw_model else f"gateway:{_raw_model}" + +SYSTEM_PROMPT = """\ +You are tau, a focused coding assistant running inside a terminal TUI. +Keep replies concise and use code blocks when showing code. +""" + + +# --------------------------------------------------------------------------- +# Widgets +# --------------------------------------------------------------------------- + + +class Bubble(textual.widgets.Static): + """One message in the transcript. Role drives the styling.""" + + DEFAULT_CSS = """ + Bubble { + width: 1fr; + padding: 0 1; + margin: 0 0 1 0; + } + Bubble.user { + color: $text; + } + Bubble.assistant { + color: $accent; + } + Bubble.system { + color: $text-muted; + text-style: italic; + } + """ + + def __init__(self, role: str, initial: str = "") -> None: + super().__init__() + self.add_class(role) + self._role = role + self._text = rich.text.Text() + if initial: + self.append(initial) + else: + self._redraw() + + def append(self, chunk: str) -> None: + self._text.append(chunk) + self._redraw() + + def _redraw(self) -> None: + self.update(self._text) + + +class Transcript(textual.containers.VerticalScroll): + """Scrolling list of bubbles.""" + + DEFAULT_CSS = """ + Transcript { + height: 1fr; + padding: 1 2 0 2; + scrollbar-gutter: stable; + } + """ + + def add_bubble(self, role: str, text: str = "") -> Bubble: + bubble = Bubble(role, text) + self.mount(bubble) + self.scroll_end(animate=False) + return bubble + + +class Composer(textual.widgets.TextArea): + """Multi-line input that grows with its content. + + Enter submits. Shift+Enter (or alt+enter, depending on terminal) + inserts a newline. Height tracks the wrapped line count between + ``MIN_LINES`` and ``MAX_LINES``. + """ + + MIN_LINES = 1 + MAX_LINES = 10 + + class Submitted(textual.message.Message): + def __init__(self, value: str) -> None: + super().__init__() + self.value = value + + def __init__(self, *, placeholder: str = "", id: str | None = None) -> None: + super().__init__( + id=id, + placeholder=placeholder, + soft_wrap=True, + show_line_numbers=False, + # No compact=True: compact mode sets `border: none !important` + # which would override the rounded border we draw below. + ) + + def on_mount(self) -> None: + self.refresh_height() + + async def _on_key(self, event: textual.events.Key) -> None: + # Plain enter submits; shift+enter inserts a newline. + if event.key == "enter": + event.stop() + event.prevent_default() + value = self.text + self.text = "" + self.refresh_height() + self.post_message(self.Submitted(value)) + + def refresh_height(self) -> None: + # ``wrapped_document.height`` is the visual line count after soft + # wrapping. Clamp it so the composer never collapses to 0 lines + # or eats the whole screen. +2 accounts for the top+bottom of + # the rounded border (box-sizing is border-box by default). + n = max(self.MIN_LINES, min(self.MAX_LINES, self.wrapped_document.height)) + self.styles.height = n + 2 + + +# --------------------------------------------------------------------------- +# App +# --------------------------------------------------------------------------- + + +class TauApp(textual.app.App[None]): + CSS = """ + Screen { + layout: vertical; + } + #composer-dock { + dock: bottom; + height: auto; + /* dock: bottom ignores horizontal margins, so the inset lives here */ + padding: 0 1 1 1; + } + #composer { + height: 3; /* refresh_height() resizes this dynamically */ + max-height: 12; /* MAX_LINES (10) + 2 for the border */ + padding: 0 1; /* breathing room left/right of the cursor */ + border: round $surface-lighten-2; + background: $surface; + } + """ + + BINDINGS = [ + textual.binding.Binding("ctrl+c", "quit", "quit", priority=True), + textual.binding.Binding("ctrl+d", "quit", "quit", priority=True), + ] + + TITLE = "tau" + + def __init__(self) -> None: + super().__init__() + self._model = ai.get_model(MODEL_ID) + self._agent = ai.agent() + # The full conversation, including the system prompt. We mutate + # this in place so the agent always sees the entire history. + self._messages: list[ai.messages.Message] = [ + ai.system_message(SYSTEM_PROMPT), + ] + self._busy = False + + def compose(self) -> textual.app.ComposeResult: + yield Transcript(id="transcript") + with textual.containers.Container(id="composer-dock"): + yield Composer(placeholder="message tau…", id="composer") + + def on_mount(self) -> None: + transcript = self.query_one("#transcript", Transcript) + transcript.add_bubble("system", f"connected — model: {MODEL_ID}") + self.query_one("#composer", Composer).focus() + + # ------------------------------------------------------------------ + # Input → turn + # ------------------------------------------------------------------ + + async def on_text_area_changed( + self, event: textual.widgets.TextArea.Changed + ) -> None: + # Grow/shrink the composer as the user types or wraps. + if isinstance(event.text_area, Composer): + event.text_area.refresh_height() + + async def on_composer_submitted(self, event: Composer.Submitted) -> None: + text = event.value.strip() + if not text or self._busy: + return + + transcript = self.query_one("#transcript", Transcript) + transcript.add_bubble("user", text) + self._messages.append(ai.user_message(text)) + + self._set_busy(True) + self.run_turn() + + @textual.work(exclusive=True, group="turn") + async def run_turn(self) -> None: + transcript = self.query_one("#transcript", Transcript) + bubble = transcript.add_bubble("assistant") + + try: + async with self._agent.run(self._model, self._messages) as stream: + async for event in stream: + if isinstance(event, ai.events.TextDelta): + bubble.append(event.chunk) + transcript.scroll_end(animate=False) + # Persist whatever the agent added (assistant + tool turns) + # so the next turn sees the full history. + self._messages = list(stream.messages) + except Exception as exc: # noqa: BLE001 — surface errors in the UI + transcript.add_bubble("system", f"error: {exc}") + finally: + self._set_busy(False) + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + def _set_busy(self, busy: bool) -> None: + self._busy = busy + inp = self.query_one("#composer", Composer) + inp.disabled = busy + inp.placeholder = "tau is thinking…" if busy else "message tau…" + if not busy: + inp.focus() + + +if __name__ == "__main__": + TauApp().run() diff --git a/examples/tau-agent/uv.lock b/examples/tau-agent/uv.lock new file mode 100644 index 00000000..52c018dc --- /dev/null +++ b/examples/tau-agent/uv.lock @@ -0,0 +1,765 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "ai" +source = { editable = "../../" } +dependencies = [ + { name = "httpx" }, + { name = "mcp" }, + { name = "modelsdotdev" }, + { name = "pydantic" }, + { name = "typing-extensions" }, +] + +[package.metadata] +requires-dist = [ + { name = "anthropic", marker = "extra == 'anthropic'", specifier = ">=0.83.0" }, + { name = "httpx", specifier = ">=0.28.1" }, + { name = "mcp", specifier = ">=1.18.0" }, + { name = "modelsdotdev", specifier = "==0.*" }, + { name = "openai", marker = "extra == 'openai'", specifier = ">=2.14.0" }, + { name = "pydantic", specifier = ">=2.12.5" }, + { name = "typing-extensions", specifier = ">=4.15.0" }, +] +provides-extras = ["anthropic", "openai"] + +[package.metadata.requires-dev] +dev = [ + { name = "anthropic", specifier = ">=0.83.0" }, + { name = "async-solipsism", specifier = ">=0.9" }, + { name = "mypy", specifier = ">=1.11" }, + { name = "openai", specifier = ">=2.14.0" }, + { name = "pyright", specifier = ">=1.1.408" }, + { name = "pytest", specifier = ">=8.0" }, + { name = "pytest-asyncio", specifier = ">=0.24" }, + { name = "python-dotenv", specifier = ">=1.2.1" }, + { name = "rich", specifier = ">=14.2.0" }, + { name = "ruff", specifier = ">=0.8" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "certifi" +version = "2026.4.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[package]] +name = "click" +version = "8.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/63/f9e1ea081ce35720d8b92acde70daaedace594dc93b693c869e0d5910718/click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2", size = 328061, upload-time = "2026-04-22T15:11:27.506Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502, upload-time = "2026-04-22T15:11:25.044Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "cryptography" +version = "48.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/a9/db8f313fdcd85d767d4973515e1db101f9c71f95fced83233de224673757/cryptography-48.0.0.tar.gz", hash = "sha256:5c3932f4436d1cccb036cb0eaef46e6e2db91035166f1ad6505c3c9d5a635920", size = 832984, upload-time = "2026-05-04T22:59:38.133Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/3d/01f6dd9190170a5a241e0e98c2d04be3664a9e6f5b9b872cde63aff1c3dd/cryptography-48.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:0c558d2cdffd8f4bbb30fc7134c74d2ca9a476f830bb053074498fbc86f41ed6", size = 8001587, upload-time = "2026-05-04T22:57:36.803Z" }, + { url = "https://files.pythonhosted.org/packages/b2/6e/e90527eef33f309beb811cf7c982c3aeffcce8e3edb178baa4ca3ae4a6fa/cryptography-48.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f5333311663ea94f75dd408665686aaf426563556bb5283554a3539177e03b8c", size = 4690433, upload-time = "2026-05-04T22:57:40.373Z" }, + { url = "https://files.pythonhosted.org/packages/90/04/673510ed51ddff56575f306cf1617d80411ee76831ccd3097599140efdfe/cryptography-48.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7995ef305d7165c3f11ae07f2517e5a4f1d5c18da1376a0a9ed496336b69e5f3", size = 4710620, upload-time = "2026-05-04T22:57:42.935Z" }, + { url = "https://files.pythonhosted.org/packages/14/d5/e9c4ef932c8d800490c34d8bd589d64a31d5890e27ec9e9ad532be893294/cryptography-48.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:40ba1f85eaa6959837b1d51c9767e230e14612eea4ef110ee8854ada22da1bf5", size = 4696283, upload-time = "2026-05-04T22:57:45.294Z" }, + { url = "https://files.pythonhosted.org/packages/0c/29/174b9dfb60b12d59ecfc6cfa04bc88c21b42a54f01b8aae09bb6e51e4c7f/cryptography-48.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:369a6348999f94bbd53435c894377b20ab95f25a9065c283570e70150d8abc3c", size = 5296573, upload-time = "2026-05-04T22:57:47.933Z" }, + { url = "https://files.pythonhosted.org/packages/95/38/0d29a6fd7d0d1373f0c0c88a04ba20e359b257753ac497564cd660fc1d55/cryptography-48.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a0e692c683f4df67815a2d258b324e66f4738bd7a96a218c826dce4f4bd05d8f", size = 4743677, upload-time = "2026-05-04T22:57:50.067Z" }, + { url = "https://files.pythonhosted.org/packages/30/be/eef653013d5c63b6a490529e0316f9ac14a37602965d4903efed1399f32b/cryptography-48.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:18349bbc56f4743c8b12dc32e2bccb2cf83ee8b69a3bba74ef8ae857e26b3d25", size = 4330808, upload-time = "2026-05-04T22:57:52.301Z" }, + { url = "https://files.pythonhosted.org/packages/84/9e/500463e87abb7a0a0f9f256ec21123ecde0a7b5541a15e840ea54551fd81/cryptography-48.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e8eac43dfca5c4cccc6dad9a80504436fca53bb9bc3100a2386d730fbe6b602", size = 4695941, upload-time = "2026-05-04T22:57:54.603Z" }, + { url = "https://files.pythonhosted.org/packages/e3/dc/7303087450c2ec9e7fbb750e17c2abfbc658f23cbd0e54009509b7cc4091/cryptography-48.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9ccdac7d40688ecb5a3b4a604b8a88c8002e3442d6c60aead1db2a89a041560c", size = 5252579, upload-time = "2026-05-04T22:57:57.207Z" }, + { url = "https://files.pythonhosted.org/packages/d0/c0/7101d3b7215edcdc90c45da544961fd8ed2d6448f77577460fa75a8443f7/cryptography-48.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:bd72e68b06bb1e96913f97dd4901119bc17f39d4586a5adf2d3e47bc2b9d58b5", size = 4743326, upload-time = "2026-05-04T22:57:59.535Z" }, + { url = "https://files.pythonhosted.org/packages/ac/d8/5b833bad13016f562ab9d063d68199a4bd121d18458e439515601d3357ec/cryptography-48.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:59baa2cb386c4f0b9905bd6eb4c2a79a69a128408fd31d32ca4d7102d4156321", size = 4826672, upload-time = "2026-05-04T22:58:01.996Z" }, + { url = "https://files.pythonhosted.org/packages/98/e1/7074eb8bf3c135558c73fc2bcf0f5633f912e6fb87e868a55c454080ef09/cryptography-48.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9249e3cd978541d665967ac2cb2787fd6a62bddf1e75b3e347a594d7dacf4f74", size = 4972574, upload-time = "2026-05-04T22:58:03.968Z" }, + { url = "https://files.pythonhosted.org/packages/04/70/e5a1b41d325f797f39427aa44ef8baf0be500065ab6d8e10369d850d4a4f/cryptography-48.0.0-cp311-abi3-win32.whl", hash = "sha256:9c459db21422be75e2809370b829a87eb37f74cd785fc4aa9ea1e5f43b47cda4", size = 3294868, upload-time = "2026-05-04T22:58:06.467Z" }, + { url = "https://files.pythonhosted.org/packages/f4/ac/8ac51b4a5fc5932eb7ee5c517ba7dc8cd834f0048962b6b352f00f41ebf9/cryptography-48.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:5b012212e08b8dd5edc78ef54da83dd9892fd9105323b3993eff6bea65dc21d7", size = 3817107, upload-time = "2026-05-04T22:58:08.845Z" }, + { url = "https://files.pythonhosted.org/packages/6b/84/70e3feea9feea87fd7cbe77efb2712ae1e3e6edf10749dc6e95f4e60e455/cryptography-48.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:3cb07a3ed6431663cd321ea8a000a1314c74211f823e4177fefa2255e057d1ec", size = 7986556, upload-time = "2026-05-04T22:58:11.172Z" }, + { url = "https://files.pythonhosted.org/packages/89/6e/18e07a618bb5442ba10cf4df16e99c071365528aa570dfcb8c02e25a303b/cryptography-48.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c7378637d7d88016fa6791c159f698b3d3eed28ebf844ac36b9dc04a14dae18", size = 4684776, upload-time = "2026-05-04T22:58:13.712Z" }, + { url = "https://files.pythonhosted.org/packages/be/6a/4ea3b4c6c6759794d5ee2103c304a5076dc4b19ae1f9fe47dba439e159e9/cryptography-48.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc90c0b39b2e3c65ef52c804b72e3c58f8a04ab2a1871272798e5f9572c17d20", size = 4698121, upload-time = "2026-05-04T22:58:16.448Z" }, + { url = "https://files.pythonhosted.org/packages/2f/59/6ff6ad6cae03bb887da2a5860b2c9805f8dac969ef01ce563336c49bd1d1/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:76341972e1eff8b4bea859f09c0d3e64b96ce931b084f9b9b7db8ef364c30eff", size = 4690042, upload-time = "2026-05-04T22:58:18.544Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b4/fc334ed8cfd705aca282fe4d8f5ae64a8e0f74932e9feecb344610cf6e4d/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:55b7718303bf06a5753dcdccf2f3945cf18ad7bffde41b61226e4db31ab89a9c", size = 5282526, upload-time = "2026-05-04T22:58:20.75Z" }, + { url = "https://files.pythonhosted.org/packages/11/08/9f8c5386cc4cd90d8255c7cdd0f5baf459a08502a09de30dc51f553d38dc/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:a64697c641c7b1b2178e573cbc31c7c6684cd56883a478d75143dbb7118036db", size = 4733116, upload-time = "2026-05-04T22:58:23.627Z" }, + { url = "https://files.pythonhosted.org/packages/b8/77/99307d7574045699f8805aa500fa0fb83422d115b5400a064ddd306d7750/cryptography-48.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:561215ea3879cb1cbbf272867e2efda62476f240fb58c64de6b393ae19246741", size = 4316030, upload-time = "2026-05-04T22:58:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/fd/36/a608b98337af3cb2aff4818e406649d30572b7031918b04c87d979495348/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ad64688338ed4bc1a6618076ba75fd7194a5f1797ac60b47afe926285adb3166", size = 4689640, upload-time = "2026-05-04T22:58:27.747Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a6/825010a291b4438aecc1f568bc428189fc1175515223632477c07dc0a6df/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:906cbf0670286c6e0044156bc7d4af9cbb0ef6db9f73e52c3ec56ba6bdde5336", size = 5237657, upload-time = "2026-05-04T22:58:29.848Z" }, + { url = "https://files.pythonhosted.org/packages/b9/09/4e76a09b4caa29aad535ddc806f5d4c5d01885bd978bd984fbc6ca032cae/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:ea8990436d914540a40ab24b6a77c0969695ed52f4a4874c5137ccf7045a7057", size = 4732362, upload-time = "2026-05-04T22:58:32.009Z" }, + { url = "https://files.pythonhosted.org/packages/18/78/444fa04a77d0cb95f417dda20d450e13c56ba8e5220fc892a1658f44f882/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c18684a7f0cc9a3cb60328f496b8e3372def7c5d2df39ac267878b05565aaaae", size = 4819580, upload-time = "2026-05-04T22:58:34.254Z" }, + { url = "https://files.pythonhosted.org/packages/38/85/ea67067c70a1fd4be2c63d35eeed82658023021affccc7b17705f8527dd2/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9be5aafa5736574f8f15f262adc81b2a9869e2cfe9014d52a44633905b40d52c", size = 4963283, upload-time = "2026-05-04T22:58:36.376Z" }, + { url = "https://files.pythonhosted.org/packages/75/54/cc6d0f3deac3e81c7f847e8a189a12b6cdd65059b43dad25d4316abd849a/cryptography-48.0.0-cp314-cp314t-win32.whl", hash = "sha256:c17dfe85494deaeddc5ce251aebd1d60bbe6afc8b62071bb0b469431a000124f", size = 3270954, upload-time = "2026-05-04T22:58:38.791Z" }, + { url = "https://files.pythonhosted.org/packages/49/67/cc947e288c0758a4e5473d1dcb743037ab7785541265a969240b8885441a/cryptography-48.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27241b1dc9962e056062a8eef1991d02c3a24569c95975bd2322a8a52c6e5e12", size = 3797313, upload-time = "2026-05-04T22:58:40.746Z" }, + { url = "https://files.pythonhosted.org/packages/f2/63/61d4a4e1c6b6bab6ce1e213cd36a24c415d90e76d78c5eb8577c5541d2e8/cryptography-48.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:58d00498e8933e4a194f3076aee1b4a97dfec1a6da444535755822fe5d8b0b86", size = 7983482, upload-time = "2026-05-04T22:58:43.769Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ac/f5b5995b87770c693e2596559ffafe195b4033a57f14a82268a2842953f3/cryptography-48.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:614d0949f4790582d2cc25553abd09dd723025f0c0e7c67376a1d77196743d6e", size = 4683266, upload-time = "2026-05-04T22:58:46.064Z" }, + { url = "https://files.pythonhosted.org/packages/ec/c6/8b14f67e18338fbc4adb76f66c001f5c3610b3e2d1837f268f47a347dbbb/cryptography-48.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ce4bfae76319a532a2dc68f82cc32f5676ee792a983187dac07183690e5c66f", size = 4696228, upload-time = "2026-05-04T22:58:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/ea/73/f808fbae9514bd91b47875b003f13e284c8c6bdfd904b7944e803937eec1/cryptography-48.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:2eb992bbd4661238c5a397594c83f5b4dc2bc5b848c365c8f991b6780efcc5c7", size = 4689097, upload-time = "2026-05-04T22:58:50.9Z" }, + { url = "https://files.pythonhosted.org/packages/93/01/d86632d7d28db8ae83221995752eeb6639ffb374c2d22955648cf8d52797/cryptography-48.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:22a5cb272895dce158b2cacdfdc3debd299019659f42947dbdac6f32d68fe832", size = 5283582, upload-time = "2026-05-04T22:58:53.017Z" }, + { url = "https://files.pythonhosted.org/packages/02/e1/50edc7a50334807cc4791fc4a0ce7468b4a1416d9138eab358bfc9a3d70b/cryptography-48.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2b4d59804e8408e2fea7d1fbaf218e5ec984325221db76e6a241a9abd6cdd95c", size = 4730479, upload-time = "2026-05-04T22:58:55.611Z" }, + { url = "https://files.pythonhosted.org/packages/6f/af/99a582b1b1641ff5911ac559beb45097cf79efd4ead4657f578ef1af2d47/cryptography-48.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:984a20b0f62a26f48a3396c72e4bc34c66e356d356bf370053066b3b6d54634a", size = 4326481, upload-time = "2026-05-04T22:58:57.607Z" }, + { url = "https://files.pythonhosted.org/packages/90/ee/89aa26a06ef0a7d7611788ffd571a7c50e368cc6a4d5eef8b4884e866edb/cryptography-48.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5a5ed8fde7a1d09376ca0b40e68cd59c69fe23b1f9768bd5824f54681626032a", size = 4688713, upload-time = "2026-05-04T22:59:00.077Z" }, + { url = "https://files.pythonhosted.org/packages/70/ba/bcb1b0bb7a33d4c7c0c4d4c7874b4a62ae4f56113a5f4baefa362dfb1f0f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:8cd666227ef7af430aa5914a9910e0ddd703e75f039cef0825cd0da71b6b711a", size = 5238165, upload-time = "2026-05-04T22:59:02.317Z" }, + { url = "https://files.pythonhosted.org/packages/c9/70/ca4003b1ce5ca3dc3186ada51908c8a9b9ff7d5cab83cc0d43ee14ec144f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9071196d81abc88b3516ac8cdfad32e2b66dd4a5393a8e68a961e9161ddc6239", size = 4729947, upload-time = "2026-05-04T22:59:05.255Z" }, + { url = "https://files.pythonhosted.org/packages/44/a0/4ec7cf774207905aef1a8d11c3750d5a1db805eb380ee4e16df317870128/cryptography-48.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1e2d54c8be6152856a36f0882ab231e70f8ec7f14e93cf87db8a2ed056bf160c", size = 4822059, upload-time = "2026-05-04T22:59:07.802Z" }, + { url = "https://files.pythonhosted.org/packages/1e/75/a2e55f99c16fcac7b5d6c1eb19ad8e00799854d6be5ca845f9259eae1681/cryptography-48.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a5da777e32ffed6f85a7b2b3f7c5cbc88c146bfcd0a1d7baf5fcc6c52ee35dd4", size = 4960575, upload-time = "2026-05-04T22:59:09.851Z" }, + { url = "https://files.pythonhosted.org/packages/b8/23/6e6f32143ab5d8b36ca848a502c4bcd477ae75b9e1677e3530d669062578/cryptography-48.0.0-cp39-abi3-win32.whl", hash = "sha256:77a2ccbbe917f6710e05ba9adaa25fb5075620bf3ea6fb751997875aff4ae4bd", size = 3279117, upload-time = "2026-05-04T22:59:12.019Z" }, + { url = "https://files.pythonhosted.org/packages/9d/9a/0fea98a70cf1749d41d738836f6349d97945f7c89433a259a6c2642eefeb/cryptography-48.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8", size = 3792100, upload-time = "2026-05-04T22:59:14.884Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "httpx-sse" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, +] + +[[package]] +name = "idna" +version = "3.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" }, +] + +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + +[[package]] +name = "linkify-it-py" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "uc-micro-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/c9/06ea13676ef354f0af6169587ae292d3e2406e212876a413bf9eece4eb23/linkify_it_py-2.1.0.tar.gz", hash = "sha256:43360231720999c10e9328dc3691160e27a718e280673d444c38d7d3aaa3b98b", size = 29158, upload-time = "2026-03-01T07:48:47.683Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/de/88b3be5c31b22333b3ca2f6ff1de4e863d8fe45aaea7485f591970ec1d3e/linkify_it_py-2.1.0-py3-none-any.whl", hash = "sha256:0d252c1594ecba2ecedc444053db5d3a9b7ec1b0dd929c8f1d74dce89f86c05e", size = 19878, upload-time = "2026-03-01T07:48:46.098Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[package.optional-dependencies] +linkify = [ + { name = "linkify-it-py" }, +] + +[[package]] +name = "mcp" +version = "1.27.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "jsonschema" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-multipart" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/83/d1efe7c2980d8a3afa476f4e3d42d53dd54c0ab94c27bee5d755b45c8b73/mcp-1.27.1.tar.gz", hash = "sha256:0f47e1820f8f8f941466b39749eb1d1839a04caddca2bc60e9d46e8a99914924", size = 608458, upload-time = "2026-05-08T16:50:12.601Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/73/42d9596facebdb533b7f0b86c1b0364ef350d1f8ba78b1052e8a58b48b65/mcp-1.27.1-py3-none-any.whl", hash = "sha256:1af3c4203b329430fde7a87b4fcb6392a041f5cb851fd68fc674016ab4e7c06f", size = 216260, upload-time = "2026-05-08T16:50:10.547Z" }, +] + +[[package]] +name = "mdit-py-plugins" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz", hash = "sha256:a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0", size = 56114, upload-time = "2026-05-13T09:03:38.91Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl", hash = "sha256:214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d", size = 66663, upload-time = "2026-05-13T09:03:37.76Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "modelsdotdev" +version = "0.20260516.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/5e/3f7d398627ccc5abaf69095771e500275a72826be680eeff113fdeb059fa/modelsdotdev-0.20260516.1.tar.gz", hash = "sha256:af3014d1255604d71907408007593bc3793831558e9f47a8212c9aa0be175ba9", size = 756312, upload-time = "2026-05-16T15:55:09.302Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/6b/702cad29ef612c6cf0df9fbbb0c8e732a9d08a14ab482fc89a5a2ee80bed/modelsdotdev-0.20260516.1-py3-none-any.whl", hash = "sha256:88c682d390d9dfa25eada06c397b730153d30877826b57fef4230dae4d5370a9", size = 762650, upload-time = "2026-05-16T15:55:07.791Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.9.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9f/4a/0883b8e3802965322523f0b200ecf33d31f10991d0401162f4b23c698b42/platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", size = 29400, upload-time = "2026-04-09T00:04:10.812Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/07/60/1d1e59c9c90d54591469ada7d268251f71c24bdb765f1a8a832cee8c6653/pydantic_settings-2.14.1.tar.gz", hash = "sha256:e874d3bec7e787b0c9958277956ed9b4dd5de6a80e162188fdaff7c5e26fd5fa", size = 235551, upload-time = "2026-05-08T13:40:06.542Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/8d/f1af3832f5e6eb13ba94ee809e72b8ecb5eef226d27ee0bef7d963d943c7/pydantic_settings-2.14.1-py3-none-any.whl", hash = "sha256:6e3c7edfd8277687cdc598f56e5cff0e9bfff0910a3749deaa8d4401c3a2b9de", size = 60964, upload-time = "2026-05-08T13:40:04.958Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pyjwt" +version = "2.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564, upload-time = "2026-03-13T19:27:37.25Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.28" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/54/a85eb421fbdd5007bc5af39d0f4ed9fa609e0fedbfdc2adcf0b34526870e/python_multipart-0.0.28.tar.gz", hash = "sha256:8550da197eac0f7ab748961fc9509b999fa2662ea25cef857f05249f6893c0f8", size = 45314, upload-time = "2026-05-10T11:05:16.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/a2/43bbc5860b5034e2af4ef99a0e04d726ff329c43e192ef3abaa8d7ecfce5/python_multipart-0.0.28-py3-none-any.whl", hash = "sha256:10faac07eb966c3f48dc415f9dee46c04cb10d58d30a35677db8027c825ed9b6", size = 29438, upload-time = "2026-05-10T11:05:15.052Z" }, +] + +[[package]] +name = "pywin32" +version = "311" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31", size = 8706543, upload-time = "2025-07-14T20:13:20.765Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040, upload-time = "2025-07-14T20:13:22.543Z" }, + { url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102, upload-time = "2025-07-14T20:13:24.682Z" }, + { url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" }, + { url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" }, + { url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" }, + { url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" }, + { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" }, +] + +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "rpds-py" +version = "0.30.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" }, + { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" }, + { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" }, + { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" }, + { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" }, + { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" }, + { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" }, + { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" }, + { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" }, + { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" }, + { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" }, + { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" }, + { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" }, + { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" }, + { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" }, + { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" }, + { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" }, + { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" }, + { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" }, + { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" }, + { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" }, + { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" }, + { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" }, + { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" }, + { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" }, + { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" }, + { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" }, + { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" }, + { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" }, + { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" }, + { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" }, + { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" }, + { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" }, + { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" }, + { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" }, + { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" }, + { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" }, + { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" }, + { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" }, + { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" }, + { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" }, + { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" }, + { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" }, + { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" }, + { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" }, + { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" }, + { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" }, + { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" }, + { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" }, + { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" }, + { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" }, + { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" }, +] + +[[package]] +name = "sse-starlette" +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f7/2b/58abc2d1fd397e7dde08e947e05c884d8ef2f78d5e2588c17a12d42d6994/sse_starlette-3.4.4.tar.gz", hash = "sha256:07e0fa0460138baf25cdd5fb28683472c3995dc1642225191b3832d62526bcb0", size = 31819, upload-time = "2026-05-12T17:37:17.019Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/67/805710444ea8cc75fbf70b920ed431a560c4bf9c57f7d5a3117213189399/sse_starlette-3.4.4-py3-none-any.whl", hash = "sha256:3f4dd50d8aed2771a091f3a83000323fc3844541c16b4fe585ae2420cc6df973", size = 16514, upload-time = "2026-05-12T17:37:15.601Z" }, +] + +[[package]] +name = "starlette" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" }, +] + +[[package]] +name = "tau-agent" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "ai" }, + { name = "textual" }, +] + +[package.metadata] +requires-dist = [ + { name = "ai", editable = "../../" }, + { name = "textual", specifier = ">=3.0" }, +] + +[[package]] +name = "textual" +version = "8.2.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py", extra = ["linkify"] }, + { name = "mdit-py-plugins" }, + { name = "platformdirs" }, + { name = "pygments" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1c/b3/b62658f6cf808d28e4d16a07509728a7b17824f55a6d3533f017fd4566b0/textual-8.2.6.tar.gz", hash = "sha256:cef3714498a120a99278b98d4c165c278844e73db50f1db039aaabd89f2d1b63", size = 1856990, upload-time = "2026-05-13T09:56:12.281Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/b4/c2b876f445e52522824cb900f2c7db3a7c24f89d20449ef278b4195d0ecb/textual-8.2.6-py3-none-any.whl", hash = "sha256:17c92bec7ff1617bd7db2a3d9734b0c3b7d2c274c67d5eba94371ea2f99a63fd", size = 729855, upload-time = "2026-05-13T09:56:14.687Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "uc-micro-py" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/78/67/9a363818028526e2d4579334460df777115bdec1bb77c08f9db88f6389f2/uc_micro_py-2.0.0.tar.gz", hash = "sha256:c53691e495c8db60e16ffc4861a35469b0ba0821fe409a8a7a0a71864d33a811", size = 6611, upload-time = "2026-03-01T06:31:27.526Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/73/d21edf5b204d1467e06500080a50f79d49ef2b997c79123a536d4a17d97c/uc_micro_py-2.0.0-py3-none-any.whl", hash = "sha256:3603a3859af53e5a39bc7677713c78ea6589ff188d70f4fee165db88e22b242c", size = 6383, upload-time = "2026-03-01T06:31:26.257Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.47.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f6/b1/8e7077a8641086aea449e1b5752a570f1b5906c64e0a33cd6d93b63a066b/uvicorn-0.47.0.tar.gz", hash = "sha256:7c9a0ea1a9414106bbab7324609c162d8fa0cdcdcb703060987269d77c7bb533", size = 90582, upload-time = "2026-05-14T18:16:54.455Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/41/ac2dfdbc1f60c7af4f994c7a335cfa7040c01642b605d65f611cecc2a1e4/uvicorn-0.47.0-py3-none-any.whl", hash = "sha256:2c5715bc12d1892d84752049f400cd1c3cb018514967fdfeb97640443a6a9432", size = 71301, upload-time = "2026-05-14T18:16:51.762Z" }, +] From 62c05afc3c6cd5418b1f8c4058fbe410de2f99c0 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Tue, 12 May 2026 15:22:02 -0700 Subject: [PATCH 02/73] [tau] Queue user input while a turn is streaming The composer is no longer disabled mid-turn. Submissions go into a pending queue; run_turn is the sole consumer and loops until drained, popping one queued message per turn so user/assistant alternation stays clean. --- examples/tau-agent/tau.py | 55 +++++++++++++++++++++++++-------------- 1 file changed, 36 insertions(+), 19 deletions(-) diff --git a/examples/tau-agent/tau.py b/examples/tau-agent/tau.py index cab546a4..8346c6dc 100644 --- a/examples/tau-agent/tau.py +++ b/examples/tau-agent/tau.py @@ -185,6 +185,10 @@ def __init__(self) -> None: ai.system_message(SYSTEM_PROMPT), ] self._busy = False + # User messages typed while a turn is streaming. Drained one at + # a time at the end of each turn so user/assistant alternation + # stays clean. + self._pending: list[str] = [] def compose(self) -> textual.app.ComposeResult: yield Transcript(id="transcript") @@ -209,32 +213,42 @@ async def on_text_area_changed( async def on_composer_submitted(self, event: Composer.Submitted) -> None: text = event.value.strip() - if not text or self._busy: + if not text: return transcript = self.query_one("#transcript", Transcript) transcript.add_bubble("user", text) - self._messages.append(ai.user_message(text)) + # All submissions enter the queue; ``run_turn`` is the sole + # consumer. The user bubble shows up immediately so the message + # feels sent even when it won't reach the model until the + # current turn finishes. + self._pending.append(text) - self._set_busy(True) - self.run_turn() + if not self._busy: + self._set_busy(True) + self.run_turn() @textual.work(exclusive=True, group="turn") async def run_turn(self) -> None: transcript = self.query_one("#transcript", Transcript) - bubble = transcript.add_bubble("assistant") try: - async with self._agent.run(self._model, self._messages) as stream: - async for event in stream: - if isinstance(event, ai.events.TextDelta): - bubble.append(event.chunk) - transcript.scroll_end(animate=False) - # Persist whatever the agent added (assistant + tool turns) - # so the next turn sees the full history. - self._messages = list(stream.messages) - except Exception as exc: # noqa: BLE001 — surface errors in the UI - transcript.add_bubble("system", f"error: {exc}") + while self._pending: + # Pop one queued message into history per turn so the + # model sees a clean user → assistant → user → … sequence. + self._messages.append(ai.user_message(self._pending.pop(0))) + bubble = transcript.add_bubble("assistant") + try: + async with self._agent.run(self._model, self._messages) as stream: + async for event in stream: + if isinstance(event, ai.events.TextDelta): + bubble.append(event.chunk) + transcript.scroll_end(animate=False) + # Persist whatever the agent added (assistant + tool + # turns) so the next turn sees the full history. + self._messages = list(stream.messages) + except Exception as exc: # noqa: BLE001 — surface in the UI + transcript.add_bubble("system", f"error: {exc}") finally: self._set_busy(False) @@ -245,10 +259,13 @@ async def run_turn(self) -> None: def _set_busy(self, busy: bool) -> None: self._busy = busy inp = self.query_one("#composer", Composer) - inp.disabled = busy - inp.placeholder = "tau is thinking…" if busy else "message tau…" - if not busy: - inp.focus() + # Composer stays enabled while busy — the user can keep typing + # and queue the next message. Only the placeholder changes. + inp.placeholder = ( + "tau is thinking… (type to queue your next message)" + if busy + else "message tau…" + ) if __name__ == "__main__": From 7985b6d6f1fc338146c10a70509eb3dcb743e01c Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Tue, 12 May 2026 15:44:57 -0700 Subject: [PATCH 03/73] [tau] Extract chat_loop into a standalone function All interaction with the `ai` library now lives in `chat_loop(app)` at the top of the file. TauApp owns public state (model, agent, messages, pending) that chat_loop reads. run_turn shrinks to a thin worker wrapper around the busy flag. --- examples/tau-agent/tau.py | 84 +++++++++++++++++++++++++-------------- 1 file changed, 54 insertions(+), 30 deletions(-) diff --git a/examples/tau-agent/tau.py b/examples/tau-agent/tau.py index 8346c6dc..bf36f340 100644 --- a/examples/tau-agent/tau.py +++ b/examples/tau-agent/tau.py @@ -33,6 +33,40 @@ """ +# =========================================================================== +# Agent loop — the only place that touches the `ai` library. +# +# Everything below this function is plain Textual widgets and app plumbing. +# Read this function to understand what tau does; read the rest to +# understand how the TUI renders it. +# =========================================================================== + + +async def chat_loop(app: TauApp) -> None: + """Drain the pending queue, running one agent turn per queued message. + + Reads from ``app.pending`` and ``app.messages``; writes streamed + text into a fresh assistant bubble on ``app.transcript``. All + interaction with the ``ai`` library lives here. + """ + while app.pending: + # Pop one queued message into history per turn so the model sees + # a clean user → assistant → user → … sequence. + app.messages.append(ai.user_message(app.pending.pop(0))) + bubble = app.transcript.add_bubble("assistant") + try: + async with app.agent.run(app.model, app.messages) as stream: + async for event in stream: + if isinstance(event, ai.events.TextDelta): + bubble.append(event.chunk) + app.transcript.scroll_end(animate=False) + # Persist whatever the agent added (assistant + tool turns) + # so the next turn sees the full history. + app.messages = list(stream.messages) + except Exception as exc: # noqa: BLE001 — surface in the UI + app.transcript.add_bubble("system", f"error: {exc}") + + # --------------------------------------------------------------------------- # Widgets # --------------------------------------------------------------------------- @@ -175,20 +209,25 @@ class TauApp(textual.app.App[None]): TITLE = "tau" + # State read by ``chat_loop``. Public on purpose — the agent + # function is meant to be readable next to the app. + model: ai.Model + agent: ai.Agent + messages: list[ai.messages.Message] + pending: list[str] + def __init__(self) -> None: super().__init__() - self._model = ai.get_model(MODEL_ID) - self._agent = ai.agent() + self.model = ai.get_model(MODEL_ID) + self.agent = ai.agent() # The full conversation, including the system prompt. We mutate # this in place so the agent always sees the entire history. - self._messages: list[ai.messages.Message] = [ - ai.system_message(SYSTEM_PROMPT), - ] - self._busy = False + self.messages = [ai.system_message(SYSTEM_PROMPT)] # User messages typed while a turn is streaming. Drained one at # a time at the end of each turn so user/assistant alternation # stays clean. - self._pending: list[str] = [] + self.pending = [] + self._busy = False def compose(self) -> textual.app.ComposeResult: yield Transcript(id="transcript") @@ -196,10 +235,13 @@ def compose(self) -> textual.app.ComposeResult: yield Composer(placeholder="message tau…", id="composer") def on_mount(self) -> None: - transcript = self.query_one("#transcript", Transcript) - transcript.add_bubble("system", f"connected — model: {MODEL_ID}") + self.transcript.add_bubble("system", f"connected — model: {MODEL_ID}") self.query_one("#composer", Composer).focus() + @property + def transcript(self) -> Transcript: + return self.query_one("#transcript", Transcript) + # ------------------------------------------------------------------ # Input → turn # ------------------------------------------------------------------ @@ -216,13 +258,12 @@ async def on_composer_submitted(self, event: Composer.Submitted) -> None: if not text: return - transcript = self.query_one("#transcript", Transcript) - transcript.add_bubble("user", text) + self.transcript.add_bubble("user", text) # All submissions enter the queue; ``run_turn`` is the sole # consumer. The user bubble shows up immediately so the message # feels sent even when it won't reach the model until the # current turn finishes. - self._pending.append(text) + self.pending.append(text) if not self._busy: self._set_busy(True) @@ -230,25 +271,8 @@ async def on_composer_submitted(self, event: Composer.Submitted) -> None: @textual.work(exclusive=True, group="turn") async def run_turn(self) -> None: - transcript = self.query_one("#transcript", Transcript) - try: - while self._pending: - # Pop one queued message into history per turn so the - # model sees a clean user → assistant → user → … sequence. - self._messages.append(ai.user_message(self._pending.pop(0))) - bubble = transcript.add_bubble("assistant") - try: - async with self._agent.run(self._model, self._messages) as stream: - async for event in stream: - if isinstance(event, ai.events.TextDelta): - bubble.append(event.chunk) - transcript.scroll_end(animate=False) - # Persist whatever the agent added (assistant + tool - # turns) so the next turn sees the full history. - self._messages = list(stream.messages) - except Exception as exc: # noqa: BLE001 — surface in the UI - transcript.add_bubble("system", f"error: {exc}") + await chat_loop(self) finally: self._set_busy(False) From 9d5feec0d0a86ce25a02db93a364dbb4f79796f6 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Tue, 12 May 2026 15:56:34 -0700 Subject: [PATCH 04/73] [tau] Don't yank scrolled-up readers down, and hide the scrollbar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Streaming deltas only call scroll_end when the transcript was already at the bottom — scroll up to read earlier output and the stream keeps writing offscreen. The scrollbar itself is hidden (scrollbar-size: 0 0) because its per-chunk thumb motion was visually noisy; arrow keys, pageup/pagedown, and mouse wheel still scroll. --- examples/tau-agent/tau.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/examples/tau-agent/tau.py b/examples/tau-agent/tau.py index bf36f340..e54e5c38 100644 --- a/examples/tau-agent/tau.py +++ b/examples/tau-agent/tau.py @@ -58,8 +58,12 @@ async def chat_loop(app: TauApp) -> None: async with app.agent.run(app.model, app.messages) as stream: async for event in stream: if isinstance(event, ai.events.TextDelta): + # Stay glued to the bottom only if we're already + # there — don't yank a scrolled-up reader down. + following = app.transcript.at_bottom bubble.append(event.chunk) - app.transcript.scroll_end(animate=False) + if following: + app.transcript.scroll_end(animate=False) # Persist whatever the agent added (assistant + tool turns) # so the next turn sees the full history. app.messages = list(stream.messages) @@ -118,7 +122,7 @@ class Transcript(textual.containers.VerticalScroll): Transcript { height: 1fr; padding: 1 2 0 2; - scrollbar-gutter: stable; + scrollbar-size: 0 0; } """ @@ -128,6 +132,16 @@ def add_bubble(self, role: str, text: str = "") -> Bubble: self.scroll_end(animate=False) return bubble + @property + def at_bottom(self) -> bool: + """True when the scrollback is at (or within 1 row of) the end. + + Used to decide whether streaming text should auto-scroll: if + the user has scrolled up to read earlier output, we don't yank + them back down on every chunk. + """ + return self.scroll_y >= self.max_scroll_y - 1 + class Composer(textual.widgets.TextArea): """Multi-line input that grows with its content. From 2a842c37823c47cd92efe0cac7cc6a37220b3230 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Tue, 12 May 2026 16:20:29 -0700 Subject: [PATCH 05/73] [tau] Implement pi's seven tools with approval-gated mutations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tools.py mirrors pi's built-in surface: read, write, edit, bash, grep, find, ls. Same schema shapes and continuation/truncation behavior: - read: 1-indexed offset/limit; head truncation at 2000 lines or 50KB with a 'use offset=N to continue' hint; first-line-too-big escape pointing at sed | head -c. - write/edit/bash: require_approval=True so the agent's default loop gates them behind a ToolApproval hook. - edit: exact-match, must-be-unique str_replace; multiple disjoint edits per call, applied right-to-left against the original file. - bash: tail truncation (errors live at the end), exit-code footer. - grep/find: skip .git/node_modules/etc; respect limit/byte caps. chat_loop now handles ToolEnd, ToolCallResult, and HookEvent — tool calls and their results render as 'tool' bubbles below the streaming text, and pending approvals turn the composer placeholder into a [y/n] prompt. Non-y/n input during an approval falls through to the message queue without resolving the hook. --- examples/tau-agent/README.md | 22 +- examples/tau-agent/tau.py | 149 ++++++++- examples/tau-agent/tools.py | 612 +++++++++++++++++++++++++++++++++++ 3 files changed, 767 insertions(+), 16 deletions(-) create mode 100644 examples/tau-agent/tools.py diff --git a/examples/tau-agent/README.md b/examples/tau-agent/README.md index 2a2cb358..5ab1cf2b 100644 --- a/examples/tau-agent/README.md +++ b/examples/tau-agent/README.md @@ -1,9 +1,23 @@ # tau-agent -`tau` is a coding-agent demo built on the `ai` library. This is the -chat-bot baseline — single process, Textual TUI, streaming replies, no -tools yet. Future iterations will grow real coding capabilities on -top. +`tau` is a coding-agent demo built on the `ai` library. Single +process, Textual TUI, streaming replies, pi-style tool surface: + +- **`read`** — read files; offset/limit pagination with continuation hints +- **`write`** — create / overwrite a file *(requires approval)* +- **`edit`** — exact-match str_replace, multiple disjoint edits per call *(requires approval)* +- **`bash`** — run a shell command in cwd, output truncated to the last 50KB / 2000 lines *(requires approval)* +- **`grep`** — regex search (skips `.git`, `node_modules`, etc.) +- **`find`** — glob match +- **`ls`** — directory listing + +Approval-gated tools fire a `ToolApproval` hook; the composer turns +into a `[y/n]` prompt mid-turn. Unrelated text typed during a +pending approval falls through to the message queue — the hook stays +pending until you give it a y or n. + +No workspace jail. The approval gate is the safety mechanism; +everything else relies on you watching the prompts. ## Setup diff --git a/examples/tau-agent/tau.py b/examples/tau-agent/tau.py index e54e5c38..4829302e 100644 --- a/examples/tau-agent/tau.py +++ b/examples/tau-agent/tau.py @@ -10,7 +10,9 @@ from __future__ import annotations +import json import os +from typing import Any import rich.text import textual @@ -21,6 +23,7 @@ import textual.message import textual.widgets import textual.worker +import tools as tools_ import ai @@ -30,8 +33,15 @@ SYSTEM_PROMPT = """\ You are tau, a focused coding assistant running inside a terminal TUI. Keep replies concise and use code blocks when showing code. + +You have access to the read, write, edit, bash, grep, find, and ls +tools. Mutating tools (write, edit, bash) require operator approval. """ +# How many characters of a tool result to show inline; the full result +# still goes to the model. +RESULT_PREVIEW_CHARS = 400 + # =========================================================================== # Agent loop — the only place that touches the `ai` library. @@ -53,17 +63,40 @@ async def chat_loop(app: TauApp) -> None: # Pop one queued message into history per turn so the model sees # a clean user → assistant → user → … sequence. app.messages.append(ai.user_message(app.pending.pop(0))) - bubble = app.transcript.add_bubble("assistant") + # One assistant bubble per turn for streamed text; tool calls + # get their own bubbles below. + text_bubble: Bubble | None = None + tool_bubbles: dict[str, Bubble] = {} try: async with app.agent.run(app.model, app.messages) as stream: async for event in stream: if isinstance(event, ai.events.TextDelta): - # Stay glued to the bottom only if we're already - # there — don't yank a scrolled-up reader down. + if text_bubble is None: + text_bubble = app.transcript.add_bubble("assistant") following = app.transcript.at_bottom - bubble.append(event.chunk) + text_bubble.append(event.chunk) if following: app.transcript.scroll_end(animate=False) + elif isinstance(event, ai.events.ToolEnd): + tc = event.tool_call + bubble = app.transcript.add_bubble( + "tool", _format_tool_call(tc.tool_name, tc.tool_args) + ) + tool_bubbles[tc.tool_call_id] = bubble + # The next text chunk should start a fresh bubble + # so tool output and prose stay separated. + text_bubble = None + elif isinstance(event, ai.events.ToolCallResult): + for part in event.results: + tb: Bubble | None = tool_bubbles.get(part.tool_call_id) + if tb is None: + tb = app.transcript.add_bubble( + "tool", + f"→ {part.tool_name}(?)", + ) + tb.append(_format_tool_result(part.result, part.is_error)) + elif isinstance(event, ai.events.HookEvent): + app.on_hook_event(event.hook) # Persist whatever the agent added (assistant + tool turns) # so the next turn sees the full history. app.messages = list(stream.messages) @@ -71,6 +104,34 @@ async def chat_loop(app: TauApp) -> None: app.transcript.add_bubble("system", f"error: {exc}") +def _format_tool_call(name: str, raw_args: str) -> str: + try: + args = json.loads(raw_args) if raw_args else {} + except json.JSONDecodeError: + return f"→ {name}({raw_args})" + rendered = ", ".join(f"{k}={_short_value(v)}" for k, v in args.items()) + return f"→ {name}({rendered})" + + +def _short_value(v: Any) -> str: + s = json.dumps(v, ensure_ascii=False) if not isinstance(v, str) else repr(v) + if len(s) > 80: + s = s[:77] + "…" + return s + + +def _format_tool_result(result: Any, is_error: bool) -> str: + text = result if isinstance(result, str) else json.dumps(result, ensure_ascii=False) + if len(text) > RESULT_PREVIEW_CHARS: + text = ( + text[:RESULT_PREVIEW_CHARS] + + f"… [+{len(text) - RESULT_PREVIEW_CHARS} chars]" + ) + marker = "✗" if is_error else "←" + indented = "\n ".join(text.splitlines() or [""]) + return f"\n {marker} {indented}" + + # --------------------------------------------------------------------------- # Widgets # --------------------------------------------------------------------------- @@ -95,6 +156,9 @@ class Bubble(textual.widgets.Static): color: $text-muted; text-style: italic; } + Bubble.tool { + color: $text-muted; + } """ def __init__(self, role: str, initial: str = "") -> None: @@ -233,7 +297,7 @@ class TauApp(textual.app.App[None]): def __init__(self) -> None: super().__init__() self.model = ai.get_model(MODEL_ID) - self.agent = ai.agent() + self.agent = ai.agent(tools=tools_.TOOLS) # The full conversation, including the system prompt. We mutate # this in place so the agent always sees the entire history. self.messages = [ai.system_message(SYSTEM_PROMPT)] @@ -242,6 +306,11 @@ def __init__(self) -> None: # stays clean. self.pending = [] self._busy = False + # Approval hooks waiting for operator y/n. FIFO queue: only the + # head hook is "active" — ``_active_hook`` mirrors it for fast + # access from the composer. + self._hook_queue: list[ai.messages.HookPart[Any]] = [] + self._active_hook: ai.messages.HookPart[Any] | None = None def compose(self) -> textual.app.ComposeResult: yield Transcript(id="transcript") @@ -272,6 +341,30 @@ async def on_composer_submitted(self, event: Composer.Submitted) -> None: if not text: return + # Approval mode: a tool is waiting for y/n. Plain y/n/yes/no + # resolves the active hook; anything else falls through and is + # queued as a regular message (the hook stays pending). + if self._active_hook is not None: + low = text.lower() + if low in ("y", "yes", "n", "no"): + granted = low in ("y", "yes") + hook = self._active_hook + self._active_hook = None + ai.resolve_hook( + hook.hook_id, + ai.tools.ToolApproval( + granted=granted, + reason="operator approved" if granted else "operator denied", + ), + ) + self.transcript.add_bubble( + "system", + f"{'approved' if granted else 'denied'}: " + f"{hook.metadata.get('tool', '?')}", + ) + self._activate_next_hook() + return + self.transcript.add_bubble("user", text) # All submissions enter the queue; ``run_turn`` is the sole # consumer. The user bubble shows up immediately so the message @@ -294,16 +387,48 @@ async def run_turn(self) -> None: # Helpers # ------------------------------------------------------------------ + # ------------------------------------------------------------------ + # Hook plumbing + # ------------------------------------------------------------------ + + def on_hook_event(self, hook: ai.messages.HookPart[Any]) -> None: + if hook.status == "pending": + self._hook_queue.append(hook) + self._activate_next_hook() + elif hook.status in ("resolved", "cancelled"): + # Drop from queue if it was sitting there waiting. + self._hook_queue = [ + h for h in self._hook_queue if h.hook_id != hook.hook_id + ] + if self._active_hook and self._active_hook.hook_id == hook.hook_id: + self._active_hook = None + self._activate_next_hook() + + def _activate_next_hook(self) -> None: + if self._active_hook is not None or not self._hook_queue: + self._refresh_placeholder() + return + self._active_hook = self._hook_queue.pop(0) + tool = self._active_hook.metadata.get("tool", "?") + self.transcript.add_bubble("system", f"approval needed: {tool}") + self._refresh_placeholder() + + def _refresh_placeholder(self) -> None: + inp = self.query_one("#composer", Composer) + if self._active_hook is not None: + tool = self._active_hook.metadata.get("tool", "?") + inp.placeholder = f"approve {tool}? [y/n]" + elif self._busy: + inp.placeholder = "tau is thinking… (type to queue your next message)" + else: + inp.placeholder = "message tau…" + def _set_busy(self, busy: bool) -> None: self._busy = busy - inp = self.query_one("#composer", Composer) # Composer stays enabled while busy — the user can keep typing - # and queue the next message. Only the placeholder changes. - inp.placeholder = ( - "tau is thinking… (type to queue your next message)" - if busy - else "message tau…" - ) + # and queue the next message. Placeholder reflects the active + # state. + self._refresh_placeholder() if __name__ == "__main__": diff --git a/examples/tau-agent/tools.py b/examples/tau-agent/tools.py new file mode 100644 index 00000000..09d3ac01 --- /dev/null +++ b/examples/tau-agent/tools.py @@ -0,0 +1,612 @@ +"""tau's coding tools — pi's seven built-ins, plain Python. + +Mirrors pi's tool surface (read, write, edit, bash, grep, find, ls) so +the model gets the same affordances. Mutating tools (write, edit, +bash) are flagged ``require_approval=True``; the agent's default loop +gates them behind a ``ToolApproval`` hook that tau renders as a y/n +prompt in the composer. + +No workspace jail — paths resolve against the process cwd and the +host (or the approval flow) is what keeps things in line. +""" + +from __future__ import annotations + +import asyncio +import dataclasses +import pathlib +import re +from typing import Literal + +import pydantic + +import ai + +# --------------------------------------------------------------------------- +# Truncation — match pi's defaults +# --------------------------------------------------------------------------- + +DEFAULT_MAX_LINES = 2000 +DEFAULT_MAX_BYTES = 50 * 1024 # 50 KB +GREP_MAX_LINE_LENGTH = 500 + +# Directories grep/find skip by default. No .gitignore support — this +# is the cheap approximation. +EXCLUDE_DIRS = frozenset( + { + ".git", + ".hg", + ".svn", + ".venv", + "venv", + "node_modules", + "__pycache__", + "dist", + "build", + ".pytest_cache", + ".mypy_cache", + ".ruff_cache", + ".next", + ".turbo", + } +) + + +@dataclasses.dataclass +class TruncationResult: + content: str + truncated: bool + truncated_by: Literal["lines", "bytes"] | None + total_lines: int + output_lines: int + total_bytes: int + output_bytes: int + first_line_exceeds_limit: bool = False + + +def format_size(n: int) -> str: + if n < 1024: + return f"{n}B" + if n < 1024 * 1024: + return f"{n / 1024:.1f}KB" + return f"{n / (1024 * 1024):.1f}MB" + + +def truncate_head( + content: str, + *, + max_lines: int = DEFAULT_MAX_LINES, + max_bytes: int = DEFAULT_MAX_BYTES, +) -> TruncationResult: + """Keep complete lines from the start until a cap is hit.""" + total_bytes = len(content.encode("utf-8")) + lines = content.split("\n") + total_lines = len(lines) + + if total_lines <= max_lines and total_bytes <= max_bytes: + return TruncationResult( + content=content, + truncated=False, + truncated_by=None, + total_lines=total_lines, + output_lines=total_lines, + total_bytes=total_bytes, + output_bytes=total_bytes, + ) + + if len(lines[0].encode("utf-8")) > max_bytes: + return TruncationResult( + content="", + truncated=True, + truncated_by="bytes", + total_lines=total_lines, + output_lines=0, + total_bytes=total_bytes, + output_bytes=0, + first_line_exceeds_limit=True, + ) + + out_lines: list[str] = [] + out_bytes = 0 + truncated_by: Literal["lines", "bytes"] = "lines" + for i, line in enumerate(lines): + if i >= max_lines: + break + line_bytes = len(line.encode("utf-8")) + (1 if i > 0 else 0) + if out_bytes + line_bytes > max_bytes: + truncated_by = "bytes" + break + out_lines.append(line) + out_bytes += line_bytes + + if len(out_lines) >= max_lines and out_bytes <= max_bytes: + truncated_by = "lines" + + out = "\n".join(out_lines) + return TruncationResult( + content=out, + truncated=True, + truncated_by=truncated_by, + total_lines=total_lines, + output_lines=len(out_lines), + total_bytes=total_bytes, + output_bytes=len(out.encode("utf-8")), + ) + + +def truncate_tail( + content: str, + *, + max_lines: int = DEFAULT_MAX_LINES, + max_bytes: int = DEFAULT_MAX_BYTES, +) -> TruncationResult: + """Keep complete lines from the end until a cap is hit. + + Used for bash output — errors and final results sit at the bottom. + """ + total_bytes = len(content.encode("utf-8")) + lines = content.split("\n") + total_lines = len(lines) + + if total_lines <= max_lines and total_bytes <= max_bytes: + return TruncationResult( + content=content, + truncated=False, + truncated_by=None, + total_lines=total_lines, + output_lines=total_lines, + total_bytes=total_bytes, + output_bytes=total_bytes, + ) + + out_lines: list[str] = [] + out_bytes = 0 + truncated_by: Literal["lines", "bytes"] = "lines" + for line in reversed(lines): + if len(out_lines) >= max_lines: + break + line_bytes = len(line.encode("utf-8")) + (1 if out_lines else 0) + if out_bytes + line_bytes > max_bytes: + truncated_by = "bytes" + break + out_lines.append(line) + out_bytes += line_bytes + + if len(out_lines) >= max_lines and out_bytes <= max_bytes: + truncated_by = "lines" + + out_lines.reverse() + out = "\n".join(out_lines) + return TruncationResult( + content=out, + truncated=True, + truncated_by=truncated_by, + total_lines=total_lines, + output_lines=len(out_lines), + total_bytes=total_bytes, + output_bytes=len(out.encode("utf-8")), + ) + + +# --------------------------------------------------------------------------- +# read +# --------------------------------------------------------------------------- + + +@ai.tool +async def read( + path: str, + offset: int | None = None, + limit: int | None = None, +) -> str: + """Read the contents of a file. + + Output is truncated to 2000 lines or 50KB (whichever is hit first). + Use offset/limit for large files; when truncated, the result ends + with a "Use offset=N to continue" hint. offset is 1-indexed. + """ + p = pathlib.Path(path).expanduser() + if not p.exists(): + raise FileNotFoundError(f"No such file: {path}") + if not p.is_file(): + raise IsADirectoryError(f"Not a file: {path}") + + text = p.read_text(encoding="utf-8", errors="replace") + all_lines = text.split("\n") + total_lines = len(all_lines) + + start = (offset - 1) if offset else 0 # 0-indexed + if start >= total_lines: + raise ValueError( + f"Offset {offset} is beyond end of file ({total_lines} lines total)" + ) + start_display = start + 1 + + if limit is not None: + end = min(start + limit, total_lines) + selected = "\n".join(all_lines[start:end]) + user_limited = True + user_end_display = end # 1-indexed inclusive + else: + selected = "\n".join(all_lines[start:]) + user_limited = False + user_end_display = total_lines + + tr = truncate_head(selected) + + if tr.first_line_exceeds_limit: + first_size = format_size(len(all_lines[start].encode("utf-8"))) + return ( + f"[Line {start_display} is {first_size}, exceeds " + f"{format_size(DEFAULT_MAX_BYTES)} limit. Use bash: " + f"sed -n '{start_display}p' {path} | head -c {DEFAULT_MAX_BYTES}]" + ) + + out = tr.content + if tr.truncated: + end_display = start + tr.output_lines # 1-indexed inclusive + next_offset = end_display + 1 + if tr.truncated_by == "lines": + out += ( + f"\n\n[Showing lines {start_display}-{end_display} of " + f"{total_lines}. Use offset={next_offset} to continue.]" + ) + else: + out += ( + f"\n\n[Showing lines {start_display}-{end_display} of " + f"{total_lines} ({format_size(DEFAULT_MAX_BYTES)} limit). " + f"Use offset={next_offset} to continue.]" + ) + elif user_limited and user_end_display < total_lines: + remaining = total_lines - user_end_display + next_offset = user_end_display + 1 + out += ( + f"\n\n[{remaining} more lines in file. " + f"Use offset={next_offset} to continue.]" + ) + + return out + + +# --------------------------------------------------------------------------- +# write +# --------------------------------------------------------------------------- + + +@ai.tool(require_approval=True) +async def write(path: str, content: str) -> str: + """Write content to a file. + + Creates the file if it doesn't exist, overwrites if it does. + Automatically creates parent directories. Use write only for new + files or complete rewrites — use edit for targeted changes. + """ + p = pathlib.Path(path).expanduser() + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(content, encoding="utf-8") + return f"Wrote {len(content)} bytes to {path}" + + +# --------------------------------------------------------------------------- +# edit +# --------------------------------------------------------------------------- + + +class TextEdit(pydantic.BaseModel): + """A single targeted str_replace edit.""" + + oldText: str = pydantic.Field( + description=( + "Exact text for one targeted replacement. It must be unique " + "in the original file and must not overlap with any other " + "edits[].oldText in the same call." + ) + ) + newText: str = pydantic.Field( + description="Replacement text for this targeted edit." + ) + + +@ai.tool(require_approval=True) +async def edit(path: str, edits: list[TextEdit]) -> str: + """Edit a single file using exact text replacement. + + Every edits[].oldText must match a unique, non-overlapping region of + the original file. Each oldText is matched against the ORIGINAL + file, not after earlier edits are applied; emit one call with + multiple disjoint edits rather than several calls. + """ + p = pathlib.Path(path).expanduser() + if not p.exists(): + raise FileNotFoundError(f"No such file: {path}") + if not p.is_file(): + raise IsADirectoryError(f"Not a file: {path}") + if not edits: + raise ValueError("edits must be non-empty") + + content = p.read_text(encoding="utf-8") + + # Resolve each edit to a (start, end) span in the original content + # and check uniqueness up front. Apply right-to-left so spans don't + # shift under us. + spans: list[tuple[int, int, str, int]] = [] # start, end, new, idx + for i, e in enumerate(edits): + if not e.oldText: + raise ValueError(f"edits[{i}].oldText is empty") + count = content.count(e.oldText) + if count == 0: + raise ValueError(f"edits[{i}].oldText not found in {path}") + if count > 1: + raise ValueError( + f"edits[{i}].oldText matches {count} times in {path}; must be unique" + ) + pos = content.index(e.oldText) + spans.append((pos, pos + len(e.oldText), e.newText, i)) + + spans.sort() + for j in range(1, len(spans)): + if spans[j][0] < spans[j - 1][1]: + raise ValueError( + f"edits[{spans[j - 1][3]}] and edits[{spans[j][3]}] overlap" + ) + + new_content = content + for start, end, new_text, _ in reversed(spans): + new_content = new_content[:start] + new_text + new_content[end:] + + p.write_text(new_content, encoding="utf-8") + return f"Successfully replaced {len(edits)} block(s) in {path}." + + +# --------------------------------------------------------------------------- +# bash +# --------------------------------------------------------------------------- + + +@ai.tool(require_approval=True) +async def bash(command: str, timeout: float | None = None) -> str: + """Execute a bash command in the current working directory. + + Returns stdout and stderr. Output is truncated to the last 2000 + lines or 50KB (whichever is hit first). Optionally provide a + timeout in seconds. + """ + proc = await asyncio.create_subprocess_shell( + command, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + ) + try: + out_b, _ = await asyncio.wait_for(proc.communicate(), timeout=timeout) + except TimeoutError: + proc.kill() + await proc.wait() + return f"[Timed out after {timeout}s]" + + text = out_b.decode("utf-8", errors="replace") + tr = truncate_tail(text) + out = tr.content + if tr.truncated: + if tr.truncated_by == "lines": + out += ( + f"\n\n[Truncated: showing last {tr.output_lines} of " + f"{tr.total_lines} lines]" + ) + else: + out += ( + f"\n\n[Truncated: showing last {format_size(tr.output_bytes)} " + f"of {format_size(tr.total_bytes)}]" + ) + + if proc.returncode and proc.returncode != 0: + out += f"\n\n[Exit code: {proc.returncode}]" + + return out or "[no output]" + + +# --------------------------------------------------------------------------- +# grep +# --------------------------------------------------------------------------- + + +@ai.tool +async def grep( + pattern: str, + path: str | None = None, + glob: str | None = None, + ignore_case: bool = False, + literal: bool = False, + context: int = 0, + limit: int = 100, +) -> str: + """Search file contents for a pattern. + + Returns matching lines as ``path:lineno:content``. Skips common + cruft directories (.git, node_modules, __pycache__, etc.) but does + NOT respect .gitignore. Output is truncated to ``limit`` matches + or 50KB. Long match lines are truncated to 500 chars. + """ + base = pathlib.Path(path).expanduser() if path else pathlib.Path.cwd() + if not base.exists(): + raise FileNotFoundError(f"No such path: {path}") + + flags = re.IGNORECASE if ignore_case else 0 + raw = re.escape(pattern) if literal else pattern + try: + pat = re.compile(raw, flags) + except re.error as e: + raise ValueError(f"Invalid regex: {e}") from e + + if base.is_file(): + files: list[pathlib.Path] = [base] + else: + candidates = base.rglob(glob) if glob else base.rglob("*") + files = [] + for f in candidates: + if not f.is_file(): + continue + try: + rel = f.relative_to(base) + except ValueError: + continue + if any(part in EXCLUDE_DIRS for part in rel.parts): + continue + files.append(f) + + hits: list[str] = [] + bytes_used = 0 + stopped_by: Literal["limit", "bytes", None] = None + + def _short(line: str) -> str: + if len(line) <= GREP_MAX_LINE_LENGTH: + return line + return line[:GREP_MAX_LINE_LENGTH] + "... [truncated]" + + for f in sorted(files): + try: + text = f.read_text(encoding="utf-8", errors="replace") + except OSError: + continue + try: + rel_path = f.relative_to(base) + except ValueError: + rel_path = f + lines = text.split("\n") + for i, line in enumerate(lines): + if not pat.search(line): + continue + if context > 0: + ctx_chunks = [] + for j in range(max(0, i - context), min(len(lines), i + context + 1)): + sep = ":" if j == i else "-" + ctx_chunks.append(f"{rel_path}{sep}{j + 1}{sep}{_short(lines[j])}") + entry = "\n".join(ctx_chunks) + else: + entry = f"{rel_path}:{i + 1}:{_short(line)}" + hits.append(entry) + bytes_used += len(entry.encode("utf-8")) + 1 + if len(hits) >= limit: + stopped_by = "limit" + break + if bytes_used > DEFAULT_MAX_BYTES: + stopped_by = "bytes" + break + if stopped_by: + break + + if not hits: + return "No matches found." + + out = "\n".join(hits) + if stopped_by == "limit": + out += f"\n\n[Stopped at {len(hits)} matches; raise limit to see more]" + elif stopped_by == "bytes": + out += f"\n\n[Stopped at {format_size(DEFAULT_MAX_BYTES)} of output]" + return out + + +# --------------------------------------------------------------------------- +# find +# --------------------------------------------------------------------------- + + +@ai.tool +async def find( + pattern: str, + path: str | None = None, + limit: int = 1000, +) -> str: + """Search for files by glob pattern. + + Returns matching file paths relative to the search directory. + Skips common cruft directories but does NOT respect .gitignore. + Output is truncated to ``limit`` results or 50KB. + """ + base = pathlib.Path(path).expanduser() if path else pathlib.Path.cwd() + if not base.exists(): + raise FileNotFoundError(f"No such path: {path}") + if not base.is_dir(): + raise NotADirectoryError(f"Not a directory: {path}") + + matches: list[str] = [] + bytes_used = 0 + stopped_by: Literal["limit", "bytes", None] = None + for p in base.rglob(pattern): + try: + rel = p.relative_to(base) + except ValueError: + continue + if any(part in EXCLUDE_DIRS for part in rel.parts): + continue + s = str(rel) + ("/" if p.is_dir() else "") + matches.append(s) + bytes_used += len(s.encode("utf-8")) + 1 + if len(matches) >= limit: + stopped_by = "limit" + break + if bytes_used > DEFAULT_MAX_BYTES: + stopped_by = "bytes" + break + + if not matches: + return "No matches found." + + out = "\n".join(sorted(matches)) + if stopped_by == "limit": + out += f"\n\n[Stopped at {limit} matches; raise limit to see more]" + elif stopped_by == "bytes": + out += f"\n\n[Stopped at {format_size(DEFAULT_MAX_BYTES)} of output]" + return out + + +# --------------------------------------------------------------------------- +# ls +# --------------------------------------------------------------------------- + + +@ai.tool +async def ls(path: str | None = None, limit: int = 500) -> str: + """List directory contents. + + Entries are sorted alphabetically; directories are suffixed with + ``/``. Includes dotfiles. Output is truncated to ``limit`` + entries. + """ + p = pathlib.Path(path).expanduser() if path else pathlib.Path.cwd() + if not p.exists(): + raise FileNotFoundError(f"No such path: {path}") + if not p.is_dir(): + raise NotADirectoryError(f"Not a directory: {path}") + + entries: list[str] = [] + for entry in sorted(p.iterdir(), key=lambda e: e.name): + name = entry.name + ("/" if entry.is_dir() else "") + entries.append(name) + if len(entries) >= limit: + break + + if not entries: + return "(empty)" + + out = "\n".join(entries) + if len(entries) >= limit: + out += f"\n\n[Stopped at {limit} entries; raise limit to see more]" + return out + + +# --------------------------------------------------------------------------- +# Tool set +# --------------------------------------------------------------------------- + +TOOLS = [read, write, edit, bash, grep, find, ls] + +__all__ = [ + "TOOLS", + "bash", + "edit", + "find", + "grep", + "ls", + "read", + "write", +] From b71b7ec216490619d6ac4a3e25328d6d3944d85f Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Tue, 12 May 2026 17:09:55 -0700 Subject: [PATCH 06/73] [tau] Approval prompt as a focusable widget above the composer Replaces the y/n-in-the-composer approval flow with a HookPrompt widget that mounts above the composer when a tool fires its approval hook. Yellow rounded border, shows the tool name + args; single-key shortcuts (y/a approve, n/d deny) resolve the hook. Focus shifts to the prompt automatically and returns to the composer on resolution. Tab cycles between prompt and composer if the user wants to look something up before deciding \u2014 the hook stays pending until y/n. Drops the parallel y/n branch from on_composer_submitted; the prompt owns the decision now. --- examples/tau-agent/tau.py | 151 ++++++++++++++++++++++++++------------ 1 file changed, 105 insertions(+), 46 deletions(-) diff --git a/examples/tau-agent/tau.py b/examples/tau-agent/tau.py index 4829302e..a7af1db8 100644 --- a/examples/tau-agent/tau.py +++ b/examples/tau-agent/tau.py @@ -255,6 +255,68 @@ def refresh_height(self) -> None: self.styles.height = n + 2 +class HookPrompt(textual.widgets.Static): + """Approval prompt for a pending tool-approval hook. + + Mounts above the composer when a hook fires. Focusable; single-key + shortcuts (``y``/``a`` approve, ``n``/``d`` deny) resolve it. + Tab/shift-tab cycles focus back to the composer if the user wants + to look something up before deciding — the hook stays pending and + the agent stays blocked until y/n. + """ + + DEFAULT_CSS = """ + HookPrompt { + height: auto; + padding: 0 1; + border: round $warning; + background: $surface; + margin-bottom: 1; + } + HookPrompt:focus { + border: round $warning; + background: $surface-lighten-1; + } + """ + + BINDINGS = [ + textual.binding.Binding("y,a", "decide(True)", "approve", show=True), + textual.binding.Binding("n,d", "decide(False)", "deny", show=True), + ] + + can_focus = True + + class Decided(textual.message.Message): + def __init__(self, hook_id: str, granted: bool) -> None: + super().__init__() + self.hook_id = hook_id + self.granted = granted + + def __init__(self, hook: ai.messages.HookPart[Any]) -> None: + super().__init__() + self._hook_id = hook.hook_id + tool = hook.metadata.get("tool", "?") + kwargs = hook.metadata.get("kwargs", {}) or {} + body = rich.text.Text() + body.append("approve ", style="bold yellow") + body.append(tool, style="bold") + body.append("?\n") + body.append(" " + _format_kwargs(kwargs), style="dim") + body.append("\n ") + body.append("[y]", style="bold green") + body.append(" approve ") + body.append("[n]", style="bold red") + body.append(" deny") + self.update(body) + + def action_decide(self, granted: bool) -> None: + self.post_message(self.Decided(self._hook_id, granted)) + + +def _format_kwargs(kwargs: dict[str, Any]) -> str: + return ", ".join(f"{k}={_short_value(v)}" for k, v in kwargs.items()) or "—" + + # --------------------------------------------------------------------------- # App # --------------------------------------------------------------------------- @@ -268,6 +330,7 @@ class TauApp(textual.app.App[None]): #composer-dock { dock: bottom; height: auto; + layout: vertical; /* dock: bottom ignores horizontal margins, so the inset lives here */ padding: 0 1 1 1; } @@ -315,6 +378,7 @@ def __init__(self) -> None: def compose(self) -> textual.app.ComposeResult: yield Transcript(id="transcript") with textual.containers.Container(id="composer-dock"): + # Hook prompts get mounted here via ``before=#composer``. yield Composer(placeholder="message tau…", id="composer") def on_mount(self) -> None: @@ -341,30 +405,6 @@ async def on_composer_submitted(self, event: Composer.Submitted) -> None: if not text: return - # Approval mode: a tool is waiting for y/n. Plain y/n/yes/no - # resolves the active hook; anything else falls through and is - # queued as a regular message (the hook stays pending). - if self._active_hook is not None: - low = text.lower() - if low in ("y", "yes", "n", "no"): - granted = low in ("y", "yes") - hook = self._active_hook - self._active_hook = None - ai.resolve_hook( - hook.hook_id, - ai.tools.ToolApproval( - granted=granted, - reason="operator approved" if granted else "operator denied", - ), - ) - self.transcript.add_bubble( - "system", - f"{'approved' if granted else 'denied'}: " - f"{hook.metadata.get('tool', '?')}", - ) - self._activate_next_hook() - return - self.transcript.add_bubble("user", text) # All submissions enter the queue; ``run_turn`` is the sole # consumer. The user bubble shows up immediately so the message @@ -383,10 +423,6 @@ async def run_turn(self) -> None: finally: self._set_busy(False) - # ------------------------------------------------------------------ - # Helpers - # ------------------------------------------------------------------ - # ------------------------------------------------------------------ # Hook plumbing # ------------------------------------------------------------------ @@ -401,34 +437,57 @@ def on_hook_event(self, hook: ai.messages.HookPart[Any]) -> None: h for h in self._hook_queue if h.hook_id != hook.hook_id ] if self._active_hook and self._active_hook.hook_id == hook.hook_id: - self._active_hook = None + self._dismiss_active_prompt() self._activate_next_hook() def _activate_next_hook(self) -> None: if self._active_hook is not None or not self._hook_queue: - self._refresh_placeholder() return - self._active_hook = self._hook_queue.pop(0) - tool = self._active_hook.metadata.get("tool", "?") - self.transcript.add_bubble("system", f"approval needed: {tool}") - self._refresh_placeholder() + hook = self._hook_queue.pop(0) + self._active_hook = hook + prompt = HookPrompt(hook) + dock = self.query_one("#composer-dock", textual.containers.Container) + composer = self.query_one("#composer", Composer) + dock.mount(prompt, before=composer) + prompt.focus() + + def _dismiss_active_prompt(self) -> None: + for prompt in self.query(HookPrompt).results(): + prompt.remove() + self._active_hook = None + self.query_one("#composer", Composer).focus() - def _refresh_placeholder(self) -> None: - inp = self.query_one("#composer", Composer) - if self._active_hook is not None: - tool = self._active_hook.metadata.get("tool", "?") - inp.placeholder = f"approve {tool}? [y/n]" - elif self._busy: - inp.placeholder = "tau is thinking… (type to queue your next message)" - else: - inp.placeholder = "message tau…" + async def on_hook_prompt_decided(self, event: HookPrompt.Decided) -> None: + # The widget only fires this for the currently-displayed hook, + # which is also ``self._active_hook``. + hook = self._active_hook + if hook is None or hook.hook_id != event.hook_id: + return + ai.resolve_hook( + hook.hook_id, + ai.tools.ToolApproval( + granted=event.granted, + reason="operator approved" if event.granted else "operator denied", + ), + ) + self.transcript.add_bubble( + "system", + f"{'approved' if event.granted else 'denied'}: " + f"{hook.metadata.get('tool', '?')}", + ) + self._dismiss_active_prompt() + self._activate_next_hook() def _set_busy(self, busy: bool) -> None: self._busy = busy # Composer stays enabled while busy — the user can keep typing - # and queue the next message. Placeholder reflects the active - # state. - self._refresh_placeholder() + # and queue the next message. Only the placeholder changes. + inp = self.query_one("#composer", Composer) + inp.placeholder = ( + "tau is thinking… (type to queue your next message)" + if busy + else "message tau…" + ) if __name__ == "__main__": From 7b1fd0225a787b8d87493032c90a829d4db36781 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Tue, 12 May 2026 17:13:07 -0700 Subject: [PATCH 07/73] [tau] Stop gating write and edit behind approval MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only bash still requires approval. File mutations through write/edit are fine — they're targeted, reversible, and the approval prompt became friction more than safety once both fired several times per turn. --- examples/tau-agent/README.md | 4 ++-- examples/tau-agent/tools.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/examples/tau-agent/README.md b/examples/tau-agent/README.md index 5ab1cf2b..f60651cb 100644 --- a/examples/tau-agent/README.md +++ b/examples/tau-agent/README.md @@ -4,8 +4,8 @@ process, Textual TUI, streaming replies, pi-style tool surface: - **`read`** — read files; offset/limit pagination with continuation hints -- **`write`** — create / overwrite a file *(requires approval)* -- **`edit`** — exact-match str_replace, multiple disjoint edits per call *(requires approval)* +- **`write`** — create / overwrite a file +- **`edit`** — exact-match str_replace, multiple disjoint edits per call - **`bash`** — run a shell command in cwd, output truncated to the last 50KB / 2000 lines *(requires approval)* - **`grep`** — regex search (skips `.git`, `node_modules`, etc.) - **`find`** — glob match diff --git a/examples/tau-agent/tools.py b/examples/tau-agent/tools.py index 09d3ac01..30be2baa 100644 --- a/examples/tau-agent/tools.py +++ b/examples/tau-agent/tools.py @@ -273,7 +273,7 @@ async def read( # --------------------------------------------------------------------------- -@ai.tool(require_approval=True) +@ai.tool async def write(path: str, content: str) -> str: """Write content to a file. @@ -307,7 +307,7 @@ class TextEdit(pydantic.BaseModel): ) -@ai.tool(require_approval=True) +@ai.tool async def edit(path: str, edits: list[TextEdit]) -> str: """Edit a single file using exact text replacement. From 289285981837bec43fe619911fe3b4f2b1febe1b Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Tue, 12 May 2026 17:19:36 -0700 Subject: [PATCH 08/73] [tau] Add TAU_ADVERTISE=1 flag to include co-author trailer in commit messages When TAU_ADVERTISE=1 is set, appends an instruction to the system prompt asking the model to include a Co-authored-by trailer in any commit messages it writes or suggests. Co-authored-by: anthropic/claude-sonnet-4.6, via tau --- examples/tau-agent/tau.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/examples/tau-agent/tau.py b/examples/tau-agent/tau.py index a7af1db8..d2d9019f 100644 --- a/examples/tau-agent/tau.py +++ b/examples/tau-agent/tau.py @@ -30,13 +30,22 @@ _raw_model = os.environ.get("TAU_MODEL", "gateway:anthropic/claude-opus-4.6") MODEL_ID = _raw_model if ":" in _raw_model else f"gateway:{_raw_model}" +_ADVERTISE = os.environ.get("TAU_ADVERTISE", "") == "1" + SYSTEM_PROMPT = """\ You are tau, a focused coding assistant running inside a terminal TUI. Keep replies concise and use code blocks when showing code. You have access to the read, write, edit, bash, grep, find, and ls tools. Mutating tools (write, edit, bash) require operator approval. +""" + ( + f""" +When writing or suggesting commit messages, always include a trailer line: + + Co-authored-by: {MODEL_ID}, via tau """ + if _ADVERTISE else "" +) # How many characters of a tool result to show inline; the full result # still goes to the model. From e98dd03f61f2ccebb264fb68bbaa2e60664a75fd Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Tue, 12 May 2026 18:10:02 -0700 Subject: [PATCH 09/73] [tau] Add session history with persist/resume support Co-authored-by: anthropic/claude-opus-4.6, via tau --- .gitignore | 1 + examples/tau-agent/.gitignore | 17 +++ examples/tau-agent/pyproject.toml | 11 ++ examples/tau-agent/session.py | 182 ++++++++++++++++++++++++++++++ examples/tau-agent/tau.py | 163 ++++++++++++++++++++++++-- examples/tau-agent/tools.py | 3 +- examples/tau-agent/uv.lock | 33 ++++++ 7 files changed, 397 insertions(+), 13 deletions(-) create mode 100644 examples/tau-agent/session.py diff --git a/.gitignore b/.gitignore index f766fe51..ba7e58f6 100644 --- a/.gitignore +++ b/.gitignore @@ -216,3 +216,4 @@ __marimo__/ .claude/ .codex +.tau/ diff --git a/examples/tau-agent/.gitignore b/examples/tau-agent/.gitignore index d6a0ef4e..d2ad3c26 100644 --- a/examples/tau-agent/.gitignore +++ b/examples/tau-agent/.gitignore @@ -1 +1,18 @@ +<<<<<<< HEAD .tau/ +||||||| parent of 9884d0a ([tau] Add session history with persist/resume support) +======= +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +.venv/ +dist/ + +# Environment +.env +.env*.local + +# Tau session history +.tau/ +>>>>>>> 9884d0a ([tau] Add session history with persist/resume support) diff --git a/examples/tau-agent/pyproject.toml b/examples/tau-agent/pyproject.toml index 7e749f7f..eab269e6 100644 --- a/examples/tau-agent/pyproject.toml +++ b/examples/tau-agent/pyproject.toml @@ -10,3 +10,14 @@ dependencies = [ [tool.uv.sources] ai = { path = "../..", editable = true } + +[tool.ruff] +target-version = "py313" + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B", "SIM"] + +[dependency-groups] +dev = [ + "ruff>=0.15.12", +] diff --git a/examples/tau-agent/session.py b/examples/tau-agent/session.py new file mode 100644 index 00000000..70338815 --- /dev/null +++ b/examples/tau-agent/session.py @@ -0,0 +1,182 @@ +"""Session history — persist and resume conversations. + +Sessions are stored as JSONL files under ``.tau/sessions/``. Each line +is a JSON-serialised ``ai.messages.Message``. The first line is always +a metadata object (not a Message) carrying session-level info: + + {"meta": true, "session_id": "...", "model": "...", "cwd": "...", "created": "..."} + +Usage: + # New session (default) + uv run python tau.py + + # Resume the most recent session + uv run python tau.py --resume + + # Resume a specific session by ID (or prefix) + uv run python tau.py --session 20250101-120000 + + # List saved sessions + uv run python tau.py --list +""" + +from __future__ import annotations + +import json +import os +import pathlib +from datetime import UTC, datetime +from typing import Any + +import ai + +# --------------------------------------------------------------------------- +# Paths +# --------------------------------------------------------------------------- + +SESSIONS_DIR = pathlib.Path(".tau") / "sessions" + + +def _ensure_dir() -> None: + SESSIONS_DIR.mkdir(parents=True, exist_ok=True) + + +# --------------------------------------------------------------------------- +# Session ID +# --------------------------------------------------------------------------- + + +def new_session_id() -> str: + """Timestamp-based, human-readable session ID.""" + return datetime.now(UTC).strftime("%Y%m%d-%H%M%S") + + +# --------------------------------------------------------------------------- +# Metadata +# --------------------------------------------------------------------------- + + +def _meta_line(session_id: str, model: str) -> str: + return json.dumps( + { + "meta": True, + "session_id": session_id, + "model": model, + "cwd": os.getcwd(), + "created": datetime.now(UTC).isoformat(), + }, + ensure_ascii=False, + ) + + +def _read_meta(path: pathlib.Path) -> dict[str, Any] | None: + try: + with path.open("r", encoding="utf-8") as f: + first = f.readline().strip() + if first: + obj = json.loads(first) + if isinstance(obj, dict) and obj.get("meta"): + return obj + except (OSError, json.JSONDecodeError): + pass + return None + + +# --------------------------------------------------------------------------- +# Writing +# --------------------------------------------------------------------------- + + +def create_session(session_id: str, model: str) -> pathlib.Path: + """Create a new JSONL session file and write the metadata header.""" + _ensure_dir() + path = SESSIONS_DIR / f"{session_id}.jsonl" + with path.open("w", encoding="utf-8") as f: + f.write(_meta_line(session_id, model) + "\n") + return path + + +def append_messages( + path: pathlib.Path, + messages: list[ai.messages.Message], + *, + after: int = 0, +) -> int: + """Append new messages to the session file. + + ``after`` is the count of messages already written (excluding the + metadata line). Only messages from ``messages[after:]`` are + appended. Returns the new total written count. + """ + new = messages[after:] + if not new: + return after + with path.open("a", encoding="utf-8") as f: + for msg in new: + f.write(msg.model_dump_json() + "\n") + return after + len(new) + + +# --------------------------------------------------------------------------- +# Reading / resuming +# --------------------------------------------------------------------------- + + +def list_sessions() -> list[dict[str, Any]]: + """Return metadata dicts for all sessions, newest first.""" + _ensure_dir() + sessions: list[dict[str, Any]] = [] + for p in sorted(SESSIONS_DIR.glob("*.jsonl"), reverse=True): + meta = _read_meta(p) + if meta is not None: + meta["_path"] = str(p) + sessions.append(meta) + return sessions + + +def resolve_session(session_id: str | None) -> pathlib.Path | None: + """Find a session file. + + - ``None`` → most recent session + - exact match → that session + - prefix match → first match (newest first) + """ + _ensure_dir() + files = sorted(SESSIONS_DIR.glob("*.jsonl"), reverse=True) + if not files: + return None + if session_id is None: + return files[0] + # Exact + exact = SESSIONS_DIR / f"{session_id}.jsonl" + if exact.exists(): + return exact + # Prefix + for f in files: + if f.stem.startswith(session_id): + return f + return None + + +def load_messages( + path: pathlib.Path, +) -> tuple[dict[str, Any], list[ai.messages.Message]]: + """Load session metadata + messages from a JSONL file. + + Returns ``(meta_dict, messages_list)``. The system message is + included in the list (it's persisted like any other message). + """ + meta: dict[str, Any] = {} + messages: list[ai.messages.Message] = [] + with path.open("r", encoding="utf-8") as f: + for lineno, line in enumerate(f, 1): + line = line.strip() + if not line: + continue + if lineno == 1: + obj = json.loads(line) + if isinstance(obj, dict) and obj.get("meta"): + meta = obj + continue + messages.append(ai.messages.Message.model_validate_json(line)) + return meta, messages diff --git a/examples/tau-agent/tau.py b/examples/tau-agent/tau.py index d2d9019f..53c73ba4 100644 --- a/examples/tau-agent/tau.py +++ b/examples/tau-agent/tau.py @@ -2,18 +2,27 @@ Single-process Textual TUI. The user types a message, it gets appended to a running conversation history, and the agent streams its reply into -a new assistant bubble. No tools yet — this is the chat-bot baseline -we'll grow real coding capabilities on top of. +a new assistant bubble. - uv run python tau.py +Sessions are persisted to ``.tau/sessions/`` as JSONL files and can be +resumed: + + uv run python tau.py # new session + uv run python tau.py --resume # resume most recent session + uv run python tau.py --session ID # resume a specific session + uv run python tau.py --list # list saved sessions """ from __future__ import annotations +import argparse import json import os +import pathlib +import sys from typing import Any +import ai import rich.text import textual import textual.app @@ -23,9 +32,9 @@ import textual.message import textual.widgets import textual.worker -import tools as tools_ -import ai +import session as session_ +import tools as tools_ _raw_model = os.environ.get("TAU_MODEL", "gateway:anthropic/claude-opus-4.6") MODEL_ID = _raw_model if ":" in _raw_model else f"gateway:{_raw_model}" @@ -44,7 +53,8 @@ Co-authored-by: {MODEL_ID}, via tau """ - if _ADVERTISE else "" + if _ADVERTISE + else "" ) # How many characters of a tool result to show inline; the full result @@ -72,6 +82,8 @@ async def chat_loop(app: TauApp) -> None: # Pop one queued message into history per turn so the model sees # a clean user → assistant → user → … sequence. app.messages.append(ai.user_message(app.pending.pop(0))) + # Persist the user message immediately. + app._save_messages() # One assistant bubble per turn for streamed text; tool calls # get their own bubbles below. text_bubble: Bubble | None = None @@ -109,6 +121,7 @@ async def chat_loop(app: TauApp) -> None: # Persist whatever the agent added (assistant + tool turns) # so the next turn sees the full history. app.messages = list(stream.messages) + app._save_messages() except Exception as exc: # noqa: BLE001 — surface in the UI app.transcript.add_bubble("system", f"error: {exc}") @@ -366,17 +379,21 @@ class TauApp(textual.app.App[None]): messages: list[ai.messages.Message] pending: list[str] - def __init__(self) -> None: + def __init__( + self, + *, + resume_path: pathlib.Path | None = None, + ) -> None: super().__init__() self.model = ai.get_model(MODEL_ID) self.agent = ai.agent(tools=tools_.TOOLS) # The full conversation, including the system prompt. We mutate # this in place so the agent always sees the entire history. - self.messages = [ai.system_message(SYSTEM_PROMPT)] + self.messages: list[ai.messages.Message] = [ai.system_message(SYSTEM_PROMPT)] # User messages typed while a turn is streaming. Drained one at # a time at the end of each turn so user/assistant alternation # stays clean. - self.pending = [] + self.pending: list[str] = [] self._busy = False # Approval hooks waiting for operator y/n. FIFO queue: only the # head hook is "active" — ``_active_hook`` mirrors it for fast @@ -384,6 +401,12 @@ def __init__(self) -> None: self._hook_queue: list[ai.messages.HookPart[Any]] = [] self._active_hook: ai.messages.HookPart[Any] | None = None + # Session history — every message is persisted to a JSONL file. + self._resume_path = resume_path + self._session_id: str = "" + self._session_path: pathlib.Path | None = None + self._saved_count: int = 0 # messages already written to disk + def compose(self) -> textual.app.ComposeResult: yield Transcript(id="transcript") with textual.containers.Container(id="composer-dock"): @@ -391,9 +414,64 @@ def compose(self) -> textual.app.ComposeResult: yield Composer(placeholder="message tau…", id="composer") def on_mount(self) -> None: - self.transcript.add_bubble("system", f"connected — model: {MODEL_ID}") + if self._resume_path is not None: + self._restore_session(self._resume_path) + else: + self._start_new_session() self.query_one("#composer", Composer).focus() + # ------------------------------------------------------------------ + # Session persistence + # ------------------------------------------------------------------ + + def _start_new_session(self) -> None: + self._session_id = session_.new_session_id() + self._session_path = session_.create_session(self._session_id, MODEL_ID) + # Persist the system message. + self._saved_count = session_.append_messages( + self._session_path, self.messages, after=0 + ) + self.transcript.add_bubble( + "system", + f"connected — model: {MODEL_ID} session: {self._session_id}", + ) + + def _restore_session(self, path: pathlib.Path) -> None: + meta, messages = session_.load_messages(path) + self._session_id = meta.get("session_id", path.stem) + self._session_path = path + if messages: + self.messages = messages + self._saved_count = len(self.messages) # already on disk + # Replay conversation into transcript bubbles. + for msg in self.messages: + if msg.role == "system": + continue # don't clutter the UI with the system prompt + if msg.role == "user": + self.transcript.add_bubble("user", msg.text) + elif msg.role == "assistant": + self.transcript.add_bubble("assistant", msg.text) + elif msg.role == "tool": + for part in msg.parts: + if hasattr(part, "result"): + preview = _format_tool_result( + part.result, getattr(part, "is_error", False) + ) + self.transcript.add_bubble("tool", preview) + self.transcript.add_bubble( + "system", + f"resumed session {self._session_id} " + f"({len(self.messages) - 1} messages) — model: {MODEL_ID}", + ) + + def _save_messages(self) -> None: + """Append any new messages to the session JSONL file.""" + if self._session_path is None: + return + self._saved_count = session_.append_messages( + self._session_path, self.messages, after=self._saved_count + ) + @property def transcript(self) -> Transcript: return self.query_one("#transcript", Transcript) @@ -499,5 +577,68 @@ def _set_busy(self, busy: bool) -> None: ) +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + prog="tau", + description="tau — a coding-agent TUI", + ) + group = parser.add_mutually_exclusive_group() + group.add_argument( + "--resume", + "-r", + action="store_true", + default=False, + help="Resume the most recent session.", + ) + group.add_argument( + "--session", + "-s", + metavar="ID", + default=None, + help="Resume a specific session by ID (or unique prefix).", + ) + group.add_argument( + "--list", + "-l", + action="store_true", + default=False, + help="List saved sessions and exit.", + ) + return parser.parse_args() + + +def _print_sessions() -> None: + sessions = session_.list_sessions() + if not sessions: + print("No saved sessions.") + return + print(f"{'SESSION ID':<20} {'MODEL':<35} {'CWD'}") + print("─" * 80) + for s in sessions: + sid = s.get("session_id", "?") + model = s.get("model", "?") + cwd = s.get("cwd", "?") + print(f"{sid:<20} {model:<35} {cwd}") + + if __name__ == "__main__": - TauApp().run() + args = _parse_args() + + if args.list: + _print_sessions() + sys.exit(0) + + resume_path: pathlib.Path | None = None + + if args.resume: + resume_path = session_.resolve_session(None) + if resume_path is None: + print("No sessions to resume.", file=sys.stderr) + sys.exit(1) + elif args.session: + resume_path = session_.resolve_session(args.session) + if resume_path is None: + print(f"Session not found: {args.session}", file=sys.stderr) + sys.exit(1) + + TauApp(resume_path=resume_path).run() diff --git a/examples/tau-agent/tools.py b/examples/tau-agent/tools.py index 30be2baa..20946173 100644 --- a/examples/tau-agent/tools.py +++ b/examples/tau-agent/tools.py @@ -18,9 +18,8 @@ import re from typing import Literal -import pydantic - import ai +import pydantic # --------------------------------------------------------------------------- # Truncation — match pi's defaults diff --git a/examples/tau-agent/uv.lock b/examples/tau-agent/uv.lock index 52c018dc..7fdf026d 100644 --- a/examples/tau-agent/uv.lock +++ b/examples/tau-agent/uv.lock @@ -663,6 +663,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" }, ] +[[package]] +name = "ruff" +version = "0.15.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/21/a7d5c126d5b557715ef81098f3db2fe20f622a039ff2e626af28d674ab80/ruff-0.15.13.tar.gz", hash = "sha256:f9d89f17f7ba7fb2ed42921f0df75da797a9a5d71bc39049e2c687cf2baf44b7", size = 4678180, upload-time = "2026-05-14T13:44:37.869Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/61/11d458dc6ac22504fd8e237b29dfd40504c7fbbcc8930402cfe51a8e63ed/ruff-0.15.13-py3-none-linux_armv6l.whl", hash = "sha256:444b580fc72fd6887e650acd3e575e18cdc79dbcf42fb4030b491057921f61f8", size = 10738279, upload-time = "2026-05-14T13:44:18.7Z" }, + { url = "https://files.pythonhosted.org/packages/86/ca/caa871ee7be718c45256fada4e16a218ee3e33f0c4a46b729a60a24912e6/ruff-0.15.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6590d009e7cb7ebf36f83dbdd44a3fa48a0994ff6f1cdc1b08006abe58f98dc7", size = 11124798, upload-time = "2026-05-14T13:44:06.427Z" }, + { url = "https://files.pythonhosted.org/packages/d3/19/43f5f2e568dddde567fc41f8471f9432c09563e19d3e617a48cfa52f8f0a/ruff-0.15.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1c26d2f66163deeb6e08d8b39fbbe983ce3c71cea06a6d7591cfd1421793c629", size = 10460761, upload-time = "2026-05-14T13:44:04.375Z" }, + { url = "https://files.pythonhosted.org/packages/99/df/cf938cd6de3003178f03ad7c1ea2a6c099468c03a35037985070b37e76be/ruff-0.15.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9dbd6f94b434f896308e4d57fb7bfde0d02b99f7a64b3bdab0fdfa6a864203a5", size = 10804451, upload-time = "2026-05-14T13:44:25.221Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7d/5d0973129b154ded2225729169d7068f26b467760b146493fde138415f23/ruff-0.15.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bf3259f3be4d181bda591da5db2571aed6853c6a048157756448020bc6c5cd22", size = 10534285, upload-time = "2026-05-14T13:44:08.888Z" }, + { url = "https://files.pythonhosted.org/packages/1f/e3/6b999bbc66cd51e5f073842bc2a3995e99c5e0e72e16b15e7261f7abf57a/ruff-0.15.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae9c17e5eb4430c154e76abc25d79a318190f5a997f38fb6b114416c5319ffc9", size = 11312063, upload-time = "2026-05-14T13:44:11.274Z" }, + { url = "https://files.pythonhosted.org/packages/af/5a/642639e9f5db04f1e97fbd6e091c6fd20725bdf072fb114d00eefb9e6eb8/ruff-0.15.13-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2e2e39bff6c341f4b577a21b801326fab0b11847f48fcaa83f00a113c9b3cb55", size = 12183079, upload-time = "2026-05-14T13:44:01.634Z" }, + { url = "https://files.pythonhosted.org/packages/19/4c/7585735f6b53b0f12de13618b2f7d250a844f018822efc899df2e7b8295f/ruff-0.15.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e8d9a8e08013542e94d3220bc5b62cc3e5ef87c5f74bff367d3fac14fab013e6", size = 11440833, upload-time = "2026-05-14T13:43:59.043Z" }, + { url = "https://files.pythonhosted.org/packages/e8/31/bf1a0803d077e679cfeee5f2f67290a0fa79c7385b5d9a8c17b9db2c48f0/ruff-0.15.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc411dfebe5eebe55ce041c6ae080eb7668955e866daa2fbb16692a784f1c4ca", size = 11434486, upload-time = "2026-05-14T13:44:27.761Z" }, + { url = "https://files.pythonhosted.org/packages/e1/4e/62c9b999875d4f14db80f277c030578f5e249c9852d65b7ac7ad0b43c041/ruff-0.15.13-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:768494eb08b9cee54e2fd27969966f74db5a57f6eaa7a90fcb3306af34dfc4bd", size = 11385189, upload-time = "2026-05-14T13:44:13.704Z" }, + { url = "https://files.pythonhosted.org/packages/fc/89/7e959047a104df3eb12863447c110140191fc5b6c4f379ea2e803fcdb0e4/ruff-0.15.13-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:fb75f9a3a7e42ffe117d734494e6c5e5cb3565d66e12612cb63d0e572a41a5b6", size = 10781380, upload-time = "2026-05-14T13:43:56.734Z" }, + { url = "https://files.pythonhosted.org/packages/ff/52/5fd18f3b88cab63e88aa11516b3b4e1e5f720e5c330f8dbe5c26210f41f8/ruff-0.15.13-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8cb74dd33bb2f6613faf7fc03b660053b5ac4f80e706d5788c6335e2a8048d51", size = 10540605, upload-time = "2026-05-14T13:44:20.748Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e0/9e35f338990d3e41a82875ff7053ffe97541dae81c9d02143177f381d572/ruff-0.15.13-py3-none-musllinux_1_2_i686.whl", hash = "sha256:7ef823f817fcd191dc934e984be9cf4094f808effa16f2542ad8e821ba02bbf2", size = 11036554, upload-time = "2026-05-14T13:44:16.256Z" }, + { url = "https://files.pythonhosted.org/packages/c2/13/070fb048c24080fba188f66371e2a92785be257ad02242066dc7255ac6e9/ruff-0.15.13-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:f345a13937bd7f09f6f5d19fa0721b0c103e00e7f62bc67089a8e5e037719e0b", size = 11528133, upload-time = "2026-05-14T13:44:22.808Z" }, + { url = "https://files.pythonhosted.org/packages/6b/8c/b1e1666aef7fc6555094d73ae6cd981701781ae85b97ceefc0eebd0b4668/ruff-0.15.13-py3-none-win32.whl", hash = "sha256:4044f94208b3b05ba0fc4a4abd0558cf4d6459bd18325eead7fd8cc66f909b41", size = 10721455, upload-time = "2026-05-14T13:44:35.697Z" }, + { url = "https://files.pythonhosted.org/packages/ab/a6/870a3e8a50590bb92be184ad928c2922f088b00d9dc5c5ec7b924ee08c22/ruff-0.15.13-py3-none-win_amd64.whl", hash = "sha256:7064884d442b7d477b4e7473d12da7f08851d2b1982763c5d3f388a19468a1a4", size = 11900409, upload-time = "2026-05-14T13:44:30.389Z" }, + { url = "https://files.pythonhosted.org/packages/9b/36/9c015cd052fca743dae8cb2aeb16b551444787467db42ceab0fc968865af/ruff-0.15.13-py3-none-win_arm64.whl", hash = "sha256:2471da9bd1068c8c064b5fd9c0c4b6dddffd6369cb1cd68b29993b1709ff1b21", size = 11179336, upload-time = "2026-05-14T13:44:33.026Z" }, +] + [[package]] name = "sse-starlette" version = "3.4.4" @@ -698,12 +723,20 @@ dependencies = [ { name = "textual" }, ] +[package.dev-dependencies] +dev = [ + { name = "ruff" }, +] + [package.metadata] requires-dist = [ { name = "ai", editable = "../../" }, { name = "textual", specifier = ">=3.0" }, ] +[package.metadata.requires-dev] +dev = [{ name = "ruff", specifier = ">=0.15.12" }] + [[package]] name = "textual" version = "8.2.6" From f954aca86259bd1183788089b3e9704bd18387ab Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Tue, 12 May 2026 18:24:45 -0700 Subject: [PATCH 10/73] [tau] Show cumulative token usage in footer bar Add a Static widget below the composer that displays running totals for input, output, cache-read, and cache-write tokens. Updated on each turn and when restoring a session. Co-authored-by: anthropic/claude-opus-4.6, via tau --- examples/tau-agent/tau.py | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/examples/tau-agent/tau.py b/examples/tau-agent/tau.py index 53c73ba4..84d40b23 100644 --- a/examples/tau-agent/tau.py +++ b/examples/tau-agent/tau.py @@ -23,6 +23,7 @@ from typing import Any import ai +import ai.types.usage import rich.text import textual import textual.app @@ -122,6 +123,8 @@ async def chat_loop(app: TauApp) -> None: # so the next turn sees the full history. app.messages = list(stream.messages) app._save_messages() + # Accumulate token usage from new messages. + app._refresh_usage() except Exception as exc: # noqa: BLE001 — surface in the UI app.transcript.add_bubble("system", f"error: {exc}") @@ -363,6 +366,11 @@ class TauApp(textual.app.App[None]): border: round $surface-lighten-2; background: $surface; } + #usage-bar { + height: 1; + padding: 0 1; + color: $text-muted; + } """ BINDINGS = [ @@ -406,12 +414,14 @@ def __init__( self._session_id: str = "" self._session_path: pathlib.Path | None = None self._saved_count: int = 0 # messages already written to disk + self._total_usage: ai.types.usage.Usage = ai.types.usage.Usage() def compose(self) -> textual.app.ComposeResult: yield Transcript(id="transcript") with textual.containers.Container(id="composer-dock"): # Hook prompts get mounted here via ``before=#composer``. yield Composer(placeholder="message tau…", id="composer") + yield textual.widgets.Static("", id="usage-bar") def on_mount(self) -> None: if self._resume_path is not None: @@ -463,6 +473,7 @@ def _restore_session(self, path: pathlib.Path) -> None: f"resumed session {self._session_id} " f"({len(self.messages) - 1} messages) — model: {MODEL_ID}", ) + self._refresh_usage() def _save_messages(self) -> None: """Append any new messages to the session JSONL file.""" @@ -472,6 +483,29 @@ def _save_messages(self) -> None: self._session_path, self.messages, after=self._saved_count ) + def _refresh_usage(self) -> None: + """Re-derive cumulative usage from all messages.""" + total = ai.types.usage.Usage() + for msg in self.messages: + if msg.usage is not None: + total = total + msg.usage + self._total_usage = total + self._update_usage_display() + + def _update_usage_display(self) -> None: + """Show cumulative token usage in the footer bar.""" + u = self._total_usage + if u.total_tokens == 0: + return + parts: list[str] = [] + parts.append(f"tokens in: {u.input_tokens:,}") + parts.append(f"out: {u.output_tokens:,}") + if u.cache_read_tokens: + parts.append(f"cache-read: {u.cache_read_tokens:,}") + if u.cache_write_tokens: + parts.append(f"cache-write: {u.cache_write_tokens:,}") + self.query_one("#usage-bar", textual.widgets.Static).update(" ".join(parts)) + @property def transcript(self) -> Transcript: return self.query_one("#transcript", Transcript) From 5db5b201496af3994d0fd4bcc15b08680f41d8ac Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Tue, 12 May 2026 18:27:03 -0700 Subject: [PATCH 11/73] [tau] Show approximate context size in usage footer Derive context estimate from the last assistant turn's input + output tokens and display it as 'ctx: ~N' in the footer bar. Co-authored-by: anthropic/claude-opus-4.6, via tau --- examples/tau-agent/tau.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/examples/tau-agent/tau.py b/examples/tau-agent/tau.py index 84d40b23..57f3fe52 100644 --- a/examples/tau-agent/tau.py +++ b/examples/tau-agent/tau.py @@ -415,6 +415,7 @@ def __init__( self._session_path: pathlib.Path | None = None self._saved_count: int = 0 # messages already written to disk self._total_usage: ai.types.usage.Usage = ai.types.usage.Usage() + self._last_usage: ai.types.usage.Usage | None = None def compose(self) -> textual.app.ComposeResult: yield Transcript(id="transcript") @@ -486,10 +487,13 @@ def _save_messages(self) -> None: def _refresh_usage(self) -> None: """Re-derive cumulative usage from all messages.""" total = ai.types.usage.Usage() + last_usage: ai.types.usage.Usage | None = None for msg in self.messages: if msg.usage is not None: total = total + msg.usage + last_usage = msg.usage self._total_usage = total + self._last_usage = last_usage self._update_usage_display() def _update_usage_display(self) -> None: @@ -498,7 +502,11 @@ def _update_usage_display(self) -> None: if u.total_tokens == 0: return parts: list[str] = [] - parts.append(f"tokens in: {u.input_tokens:,}") + # Approximate current context size: last turn's in + out. + if self._last_usage is not None: + ctx = self._last_usage.input_tokens + self._last_usage.output_tokens + parts.append(f"ctx: ~{ctx:,}") + parts.append(f"in: {u.input_tokens:,}") parts.append(f"out: {u.output_tokens:,}") if u.cache_read_tokens: parts.append(f"cache-read: {u.cache_read_tokens:,}") From 8e17737d8399946fd2d2bdd5f5739a2e446a9d83 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Tue, 12 May 2026 19:03:42 -0700 Subject: [PATCH 12/73] [tau] Enable gateway caching and improve usage display Pass providerOptions.gateway.caching=auto as params to agent.run(). Update the usage footer to show uncached input as 'in' and cache-read tokens separately as 'cached'. Co-authored-by: anthropic/claude-opus-4.6, via tau --- examples/tau-agent/tau.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/examples/tau-agent/tau.py b/examples/tau-agent/tau.py index 57f3fe52..c983e9ad 100644 --- a/examples/tau-agent/tau.py +++ b/examples/tau-agent/tau.py @@ -40,6 +40,10 @@ _raw_model = os.environ.get("TAU_MODEL", "gateway:anthropic/claude-opus-4.6") MODEL_ID = _raw_model if ":" in _raw_model else f"gateway:{_raw_model}" +STREAM_PARAMS: dict[str, Any] = { + "providerOptions": {"gateway": {"caching": "auto"}}, +} + _ADVERTISE = os.environ.get("TAU_ADVERTISE", "") == "1" SYSTEM_PROMPT = """\ @@ -90,7 +94,9 @@ async def chat_loop(app: TauApp) -> None: text_bubble: Bubble | None = None tool_bubbles: dict[str, Bubble] = {} try: - async with app.agent.run(app.model, app.messages) as stream: + async with app.agent.run( + app.model, app.messages, params=STREAM_PARAMS + ) as stream: async for event in stream: if isinstance(event, ai.events.TextDelta): if text_bubble is None: @@ -506,12 +512,12 @@ def _update_usage_display(self) -> None: if self._last_usage is not None: ctx = self._last_usage.input_tokens + self._last_usage.output_tokens parts.append(f"ctx: ~{ctx:,}") - parts.append(f"in: {u.input_tokens:,}") - parts.append(f"out: {u.output_tokens:,}") + # input_tokens includes cache-read; subtract to show uncached. + uncached_in = u.input_tokens - (u.cache_read_tokens or 0) + parts.append(f"in: {uncached_in:,}") if u.cache_read_tokens: - parts.append(f"cache-read: {u.cache_read_tokens:,}") - if u.cache_write_tokens: - parts.append(f"cache-write: {u.cache_write_tokens:,}") + parts.append(f"cached: {u.cache_read_tokens:,}") + parts.append(f"out: {u.output_tokens:,}") self.query_one("#usage-bar", textual.widgets.Static).update(" ".join(parts)) @property From a8e59686fa89b1df1f861ae375c790adf169dbd7 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Wed, 13 May 2026 00:02:40 -0700 Subject: [PATCH 13/73] [tau] Refactor chat_loop: extract _run_turn for single-turn logic Co-authored-by: anthropic/claude-opus-4.6, via tau --- examples/tau-agent/tau.py | 86 ++++++++++++++++++++------------------- 1 file changed, 44 insertions(+), 42 deletions(-) diff --git a/examples/tau-agent/tau.py b/examples/tau-agent/tau.py index c983e9ad..8f554bca 100644 --- a/examples/tau-agent/tau.py +++ b/examples/tau-agent/tau.py @@ -87,54 +87,56 @@ async def chat_loop(app: TauApp) -> None: # Pop one queued message into history per turn so the model sees # a clean user → assistant → user → … sequence. app.messages.append(ai.user_message(app.pending.pop(0))) - # Persist the user message immediately. app._save_messages() - # One assistant bubble per turn for streamed text; tool calls - # get their own bubbles below. - text_bubble: Bubble | None = None - tool_bubbles: dict[str, Bubble] = {} try: - async with app.agent.run( - app.model, app.messages, params=STREAM_PARAMS - ) as stream: - async for event in stream: - if isinstance(event, ai.events.TextDelta): - if text_bubble is None: - text_bubble = app.transcript.add_bubble("assistant") - following = app.transcript.at_bottom - text_bubble.append(event.chunk) - if following: - app.transcript.scroll_end(animate=False) - elif isinstance(event, ai.events.ToolEnd): - tc = event.tool_call - bubble = app.transcript.add_bubble( - "tool", _format_tool_call(tc.tool_name, tc.tool_args) - ) - tool_bubbles[tc.tool_call_id] = bubble - # The next text chunk should start a fresh bubble - # so tool output and prose stay separated. - text_bubble = None - elif isinstance(event, ai.events.ToolCallResult): - for part in event.results: - tb: Bubble | None = tool_bubbles.get(part.tool_call_id) - if tb is None: - tb = app.transcript.add_bubble( - "tool", - f"→ {part.tool_name}(?)", - ) - tb.append(_format_tool_result(part.result, part.is_error)) - elif isinstance(event, ai.events.HookEvent): - app.on_hook_event(event.hook) - # Persist whatever the agent added (assistant + tool turns) - # so the next turn sees the full history. - app.messages = list(stream.messages) - app._save_messages() - # Accumulate token usage from new messages. - app._refresh_usage() + await _run_turn(app) except Exception as exc: # noqa: BLE001 — surface in the UI app.transcript.add_bubble("system", f"error: {exc}") +async def _run_turn(app: TauApp) -> None: + """Execute a single agent turn, streaming events into the transcript.""" + # One assistant bubble per turn for streamed text; tool calls + # get their own bubbles below. + text_bubble: Bubble | None = None + tool_bubbles: dict[str, Bubble] = {} + async with app.agent.run(app.model, app.messages, params=STREAM_PARAMS) as stream: + async for event in stream: + if isinstance(event, ai.events.TextDelta): + if text_bubble is None: + text_bubble = app.transcript.add_bubble("assistant") + following = app.transcript.at_bottom + text_bubble.append(event.chunk) + if following: + app.transcript.scroll_end(animate=False) + elif isinstance(event, ai.events.ToolEnd): + tc = event.tool_call + bubble = app.transcript.add_bubble( + "tool", _format_tool_call(tc.tool_name, tc.tool_args) + ) + tool_bubbles[tc.tool_call_id] = bubble + # The next text chunk should start a fresh bubble + # so tool output and prose stay separated. + text_bubble = None + elif isinstance(event, ai.events.ToolCallResult): + for part in event.results: + tb: Bubble | None = tool_bubbles.get(part.tool_call_id) + if tb is None: + tb = app.transcript.add_bubble( + "tool", + f"→ {part.tool_name}(?)", + ) + tb.append(_format_tool_result(part.result, part.is_error)) + elif isinstance(event, ai.events.HookEvent): + app.on_hook_event(event.hook) + # Persist whatever the agent added (assistant + tool turns) + # so the next turn sees the full history. + app.messages = list(stream.messages) + app._save_messages() + # Accumulate token usage from new messages. + app._refresh_usage() + + def _format_tool_call(name: str, raw_args: str) -> str: try: args = json.loads(raw_args) if raw_args else {} From 63081d8e9e75eac7a453b2cbe33f9a2b56ba8915 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Wed, 13 May 2026 00:08:28 -0700 Subject: [PATCH 14/73] [tau] Add ApprovalTracker with per-command and global auto-approve Bash approval prompts now offer four options: [y] approve once, [n] deny, [!] always approve this exact command, [a] always approve all commands for the rest of the session. Co-authored-by: anthropic/claude-opus-4.6, via tau --- examples/tau-agent/tau.py | 101 ++++++++++++++++++++++++++++++-------- 1 file changed, 81 insertions(+), 20 deletions(-) diff --git a/examples/tau-agent/tau.py b/examples/tau-agent/tau.py index 8f554bca..072ff8a5 100644 --- a/examples/tau-agent/tau.py +++ b/examples/tau-agent/tau.py @@ -288,14 +288,55 @@ def refresh_height(self) -> None: self.styles.height = n + 2 +class ApprovalTracker: + """Session-scoped approval state for tool hooks. + + Tracks "always approve" decisions so subsequent identical commands + (or all commands) can be auto-resolved without prompting. + """ + + def __init__(self) -> None: + self._approve_all = False + self._approved_commands: set[str] = set() + + def check(self, hook: ai.messages.HookPart[Any]) -> bool | None: + """Return True to auto-approve, False to auto-deny, None to prompt.""" + if self._approve_all: + return True + tool = hook.metadata.get("tool", "") + kwargs = hook.metadata.get("kwargs", {}) or {} + if tool == "bash": + cmd = kwargs.get("command", "") + if cmd in self._approved_commands: + return True + return None + + def approve_command(self, hook: ai.messages.HookPart[Any]) -> None: + """Remember to always approve this exact command.""" + kwargs = hook.metadata.get("kwargs", {}) or {} + cmd = kwargs.get("command", "") + if cmd: + self._approved_commands.add(cmd) + + def approve_all(self) -> None: + """Auto-approve every future tool call this session.""" + self._approve_all = True + + class HookPrompt(textual.widgets.Static): """Approval prompt for a pending tool-approval hook. Mounts above the composer when a hook fires. Focusable; single-key - shortcuts (``y``/``a`` approve, ``n``/``d`` deny) resolve it. + shortcuts resolve it: + + - ``y`` approve once + - ``n`` deny + - ``!`` always approve this exact command + - ``a`` always approve all commands + Tab/shift-tab cycles focus back to the composer if the user wants to look something up before deciding — the hook stays pending and - the agent stays blocked until y/n. + the agent stays blocked. """ DEFAULT_CSS = """ @@ -313,17 +354,21 @@ class HookPrompt(textual.widgets.Static): """ BINDINGS = [ - textual.binding.Binding("y,a", "decide(True)", "approve", show=True), - textual.binding.Binding("n,d", "decide(False)", "deny", show=True), + textual.binding.Binding("y", "decide('yes')", "approve", show=True), + textual.binding.Binding("n", "decide('no')", "deny", show=True), + textual.binding.Binding( + "exclamation_mark", "decide('always_this')", "always this", show=True + ), + textual.binding.Binding("a", "decide('always_all')", "always all", show=True), ] can_focus = True class Decided(textual.message.Message): - def __init__(self, hook_id: str, granted: bool) -> None: + def __init__(self, hook_id: str, decision: str) -> None: super().__init__() self.hook_id = hook_id - self.granted = granted + self.decision = decision # 'yes' | 'no' | 'always_this' | 'always_all' def __init__(self, hook: ai.messages.HookPart[Any]) -> None: super().__init__() @@ -337,13 +382,17 @@ def __init__(self, hook: ai.messages.HookPart[Any]) -> None: body.append(" " + _format_kwargs(kwargs), style="dim") body.append("\n ") body.append("[y]", style="bold green") - body.append(" approve ") + body.append(" yes ") body.append("[n]", style="bold red") - body.append(" deny") + body.append(" no ") + body.append("[!]", style="bold cyan") + body.append(" always this ") + body.append("[a]", style="bold cyan") + body.append(" always all") self.update(body) - def action_decide(self, granted: bool) -> None: - self.post_message(self.Decided(self._hook_id, granted)) + def action_decide(self, decision: str) -> None: + self.post_message(self.Decided(self._hook_id, decision)) def _format_kwargs(kwargs: dict[str, Any]) -> str: @@ -416,6 +465,7 @@ def __init__( # access from the composer. self._hook_queue: list[ai.messages.HookPart[Any]] = [] self._active_hook: ai.messages.HookPart[Any] | None = None + self._approval = ApprovalTracker() # Session history — every message is persisted to a JSONL file. self._resume_path = resume_path @@ -566,6 +616,11 @@ async def run_turn(self) -> None: def on_hook_event(self, hook: ai.messages.HookPart[Any]) -> None: if hook.status == "pending": + # Check if the tracker can auto-resolve this hook. + decision = self._approval.check(hook) + if decision is not None: + self._resolve_hook(hook, granted=decision) + return self._hook_queue.append(hook) self._activate_next_hook() elif hook.status in ("resolved", "cancelled"): @@ -594,24 +649,30 @@ def _dismiss_active_prompt(self) -> None: self._active_hook = None self.query_one("#composer", Composer).focus() - async def on_hook_prompt_decided(self, event: HookPrompt.Decided) -> None: - # The widget only fires this for the currently-displayed hook, - # which is also ``self._active_hook``. - hook = self._active_hook - if hook is None or hook.hook_id != event.hook_id: - return + def _resolve_hook(self, hook: ai.messages.HookPart[Any], *, granted: bool) -> None: + """Resolve a hook and show a transcript note.""" ai.resolve_hook( hook.hook_id, ai.tools.ToolApproval( - granted=event.granted, - reason="operator approved" if event.granted else "operator denied", + granted=granted, + reason="operator approved" if granted else "operator denied", ), ) self.transcript.add_bubble( "system", - f"{'approved' if event.granted else 'denied'}: " - f"{hook.metadata.get('tool', '?')}", + f"{'approved' if granted else 'denied'}: {hook.metadata.get('tool', '?')}", ) + + async def on_hook_prompt_decided(self, event: HookPrompt.Decided) -> None: + hook = self._active_hook + if hook is None or hook.hook_id != event.hook_id: + return + granted = event.decision != "no" + if event.decision == "always_this": + self._approval.approve_command(hook) + elif event.decision == "always_all": + self._approval.approve_all() + self._resolve_hook(hook, granted=granted) self._dismiss_active_prompt() self._activate_next_hook() From f12eb933dbf97c63ece8536a58f4973cac454d1e Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Wed, 13 May 2026 00:23:33 -0700 Subject: [PATCH 15/73] [tau] Auto-approve file tools under cwd, prompt for external paths File I/O tools (read, grep, find, ls, write, edit) now fire approval hooks. Paths under the working directory are auto-approved. External paths prompt with [y] yes, [n] no, [d] allow dir, [a] always all. Read and write directories are tracked as separate categories. Co-authored-by: anthropic/claude-opus-4.6, via tau --- examples/tau-agent/tau.py | 82 +++++++++++++++++++++++++++++++++---- examples/tau-agent/tools.py | 12 +++--- 2 files changed, 79 insertions(+), 15 deletions(-) diff --git a/examples/tau-agent/tau.py b/examples/tau-agent/tau.py index 072ff8a5..1ca3b78b 100644 --- a/examples/tau-agent/tau.py +++ b/examples/tau-agent/tau.py @@ -288,16 +288,58 @@ def refresh_height(self) -> None: self.styles.height = n + 2 +# Tools grouped by category for approval purposes. +_READ_TOOLS = frozenset({"read", "grep", "find", "ls"}) +_WRITE_TOOLS = frozenset({"write", "edit"}) +_FILE_TOOLS = _READ_TOOLS | _WRITE_TOOLS + + +def _tool_path(hook: ai.messages.HookPart[Any]) -> pathlib.Path | None: + """Extract and resolve the path argument from a file-tool hook.""" + kwargs = hook.metadata.get("kwargs", {}) or {} + raw = kwargs.get("path") + if raw is None: + return None + return pathlib.Path(raw).expanduser().resolve() + + class ApprovalTracker: """Session-scoped approval state for tool hooks. Tracks "always approve" decisions so subsequent identical commands (or all commands) can be auto-resolved without prompting. + + File I/O tools are auto-approved when the target path is under the + working directory. Paths outside cwd require a prompt; one of the + options is to permanently allow a directory for reads or writes. """ def __init__(self) -> None: + self._cwd = pathlib.Path.cwd().resolve() self._approve_all = False self._approved_commands: set[str] = set() + # Extra directory trees approved per category. + self._approved_read_dirs: set[pathlib.Path] = set() + self._approved_write_dirs: set[pathlib.Path] = set() + + def _path_ok(self, tool: str, path: pathlib.Path | None) -> bool: + """Check if *path* is in an approved directory for *tool*.""" + if path is None: + # Tools like grep/find/ls default to cwd when path is None. + return True + # Always allow anything under cwd. + try: + path.relative_to(self._cwd) + return True + except ValueError: + pass + # Check extra approved dirs. + dirs = ( + self._approved_read_dirs + if tool in _READ_TOOLS + else self._approved_write_dirs + ) + return any(path == d or d in path.parents for d in dirs) def check(self, hook: ai.messages.HookPart[Any]) -> bool | None: """Return True to auto-approve, False to auto-deny, None to prompt.""" @@ -305,6 +347,8 @@ def check(self, hook: ai.messages.HookPart[Any]) -> bool | None: return True tool = hook.metadata.get("tool", "") kwargs = hook.metadata.get("kwargs", {}) or {} + if tool in _FILE_TOOLS: + return True if self._path_ok(tool, _tool_path(hook)) else None if tool == "bash": cmd = kwargs.get("command", "") if cmd in self._approved_commands: @@ -312,12 +356,24 @@ def check(self, hook: ai.messages.HookPart[Any]) -> bool | None: return None def approve_command(self, hook: ai.messages.HookPart[Any]) -> None: - """Remember to always approve this exact command.""" + """Remember to always approve this exact bash command.""" kwargs = hook.metadata.get("kwargs", {}) or {} cmd = kwargs.get("command", "") if cmd: self._approved_commands.add(cmd) + def approve_directory(self, hook: ai.messages.HookPart[Any]) -> None: + """Allow all future operations in this path's directory.""" + tool = hook.metadata.get("tool", "") + path = _tool_path(hook) + if path is None: + return + directory = path if path.is_dir() else path.parent + if tool in _READ_TOOLS: + self._approved_read_dirs.add(directory) + elif tool in _WRITE_TOOLS: + self._approved_write_dirs.add(directory) + def approve_all(self) -> None: """Auto-approve every future tool call this session.""" self._approve_all = True @@ -327,12 +383,10 @@ class HookPrompt(textual.widgets.Static): """Approval prompt for a pending tool-approval hook. Mounts above the composer when a hook fires. Focusable; single-key - shortcuts resolve it: + shortcuts resolve it. The available options depend on the tool: - - ``y`` approve once - - ``n`` deny - - ``!`` always approve this exact command - - ``a`` always approve all commands + **bash**: ``[y]`` yes ``[n]`` no ``[!]`` always this ``[a]`` all + **file I/O**: ``[y]`` yes ``[n]`` no ``[d]`` allow dir ``[a]`` all Tab/shift-tab cycles focus back to the composer if the user wants to look something up before deciding — the hook stays pending and @@ -359,6 +413,7 @@ class HookPrompt(textual.widgets.Static): textual.binding.Binding( "exclamation_mark", "decide('always_this')", "always this", show=True ), + textual.binding.Binding("d", "decide('allow_dir')", "allow dir", show=True), textual.binding.Binding("a", "decide('always_all')", "always all", show=True), ] @@ -368,13 +423,16 @@ class Decided(textual.message.Message): def __init__(self, hook_id: str, decision: str) -> None: super().__init__() self.hook_id = hook_id - self.decision = decision # 'yes' | 'no' | 'always_this' | 'always_all' + # 'yes' | 'no' | 'always_this' | 'allow_dir' | 'always_all' + self.decision = decision def __init__(self, hook: ai.messages.HookPart[Any]) -> None: super().__init__() self._hook_id = hook.hook_id tool = hook.metadata.get("tool", "?") kwargs = hook.metadata.get("kwargs", {}) or {} + is_file_tool = tool in _FILE_TOOLS + body = rich.text.Text() body.append("approve ", style="bold yellow") body.append(tool, style="bold") @@ -385,8 +443,12 @@ def __init__(self, hook: ai.messages.HookPart[Any]) -> None: body.append(" yes ") body.append("[n]", style="bold red") body.append(" no ") - body.append("[!]", style="bold cyan") - body.append(" always this ") + if is_file_tool: + body.append("[d]", style="bold cyan") + body.append(" allow dir ") + else: + body.append("[!]", style="bold cyan") + body.append(" always this ") body.append("[a]", style="bold cyan") body.append(" always all") self.update(body) @@ -670,6 +732,8 @@ async def on_hook_prompt_decided(self, event: HookPrompt.Decided) -> None: granted = event.decision != "no" if event.decision == "always_this": self._approval.approve_command(hook) + elif event.decision == "allow_dir": + self._approval.approve_directory(hook) elif event.decision == "always_all": self._approval.approve_all() self._resolve_hook(hook, granted=granted) diff --git a/examples/tau-agent/tools.py b/examples/tau-agent/tools.py index 20946173..e2ade215 100644 --- a/examples/tau-agent/tools.py +++ b/examples/tau-agent/tools.py @@ -192,7 +192,7 @@ def truncate_tail( # --------------------------------------------------------------------------- -@ai.tool +@ai.tool(require_approval=True) async def read( path: str, offset: int | None = None, @@ -272,7 +272,7 @@ async def read( # --------------------------------------------------------------------------- -@ai.tool +@ai.tool(require_approval=True) async def write(path: str, content: str) -> str: """Write content to a file. @@ -306,7 +306,7 @@ class TextEdit(pydantic.BaseModel): ) -@ai.tool +@ai.tool(require_approval=True) async def edit(path: str, edits: list[TextEdit]) -> str: """Edit a single file using exact text replacement. @@ -408,7 +408,7 @@ async def bash(command: str, timeout: float | None = None) -> str: # --------------------------------------------------------------------------- -@ai.tool +@ai.tool(require_approval=True) async def grep( pattern: str, path: str | None = None, @@ -509,7 +509,7 @@ def _short(line: str) -> str: # --------------------------------------------------------------------------- -@ai.tool +@ai.tool(require_approval=True) async def find( pattern: str, path: str | None = None, @@ -563,7 +563,7 @@ async def find( # --------------------------------------------------------------------------- -@ai.tool +@ai.tool(require_approval=True) async def ls(path: str | None = None, limit: int = 500) -> str: """List directory contents. From f94cd93f1536adc33c810a6a8320c034eed9d1a8 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Wed, 13 May 2026 00:30:43 -0700 Subject: [PATCH 16/73] [tau] Ring terminal bell on turn completion and approval prompts Co-authored-by: anthropic/claude-opus-4.6, via tau --- examples/tau-agent/tau.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/examples/tau-agent/tau.py b/examples/tau-agent/tau.py index 1ca3b78b..51749705 100644 --- a/examples/tau-agent/tau.py +++ b/examples/tau-agent/tau.py @@ -704,6 +704,7 @@ def _activate_next_hook(self) -> None: composer = self.query_one("#composer", Composer) dock.mount(prompt, before=composer) prompt.focus() + self._bell() def _dismiss_active_prompt(self) -> None: for prompt in self.query(HookPrompt).results(): @@ -740,8 +741,20 @@ async def on_hook_prompt_decided(self, event: HookPrompt.Decided) -> None: self._dismiss_active_prompt() self._activate_next_hook() + @staticmethod + def _bell() -> None: + """Ring the terminal bell to notify the operator.""" + try: + with open("/dev/tty", "w") as tty: + tty.write("\a") + tty.flush() + except OSError: + pass + def _set_busy(self, busy: bool) -> None: self._busy = busy + if not busy: + self._bell() # Composer stays enabled while busy — the user can keep typing # and queue the next message. Only the placeholder changes. inp = self.query_one("#composer", Composer) From bc616b4b9a877a7e375a42a13f969ede123e4087 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Wed, 13 May 2026 10:22:32 -0700 Subject: [PATCH 17/73] [tau] Render assistant messages as markdown via Rich Co-authored-by: anthropic/claude-opus-4.6, via tau --- examples/tau-agent/tau.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/examples/tau-agent/tau.py b/examples/tau-agent/tau.py index 51749705..cb1fabb6 100644 --- a/examples/tau-agent/tau.py +++ b/examples/tau-agent/tau.py @@ -24,6 +24,7 @@ import ai import ai.types.usage +import rich.markdown import rich.text import textual import textual.app @@ -198,18 +199,21 @@ def __init__(self, role: str, initial: str = "") -> None: super().__init__() self.add_class(role) self._role = role - self._text = rich.text.Text() + self._raw = "" if initial: self.append(initial) else: self._redraw() def append(self, chunk: str) -> None: - self._text.append(chunk) + self._raw += chunk self._redraw() def _redraw(self) -> None: - self.update(self._text) + if self._role == "assistant": + self.update(rich.markdown.Markdown(self._raw)) + else: + self.update(rich.text.Text(self._raw)) class Transcript(textual.containers.VerticalScroll): From 3d9346d85cee902c584f4b2cfad21014fbdd07cb Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Wed, 13 May 2026 10:41:44 -0700 Subject: [PATCH 18/73] [tau] ESC to interrupt running turn ESC cancels the active worker and dismisses any pending approval prompt. Partial messages are saved before the stream is closed so context isn't lost. Co-authored-by: anthropic/claude-opus-4.6, via tau --- examples/tau-agent/tau.py | 82 +++++++++++++++++++++++++-------------- 1 file changed, 52 insertions(+), 30 deletions(-) diff --git a/examples/tau-agent/tau.py b/examples/tau-agent/tau.py index cb1fabb6..bd43f3f2 100644 --- a/examples/tau-agent/tau.py +++ b/examples/tau-agent/tau.py @@ -16,6 +16,7 @@ from __future__ import annotations import argparse +import asyncio import json import os import pathlib @@ -91,6 +92,9 @@ async def chat_loop(app: TauApp) -> None: app._save_messages() try: await _run_turn(app) + except asyncio.CancelledError: + app.transcript.add_bubble("system", "interrupted") + raise except Exception as exc: # noqa: BLE001 — surface in the UI app.transcript.add_bubble("system", f"error: {exc}") @@ -101,41 +105,47 @@ async def _run_turn(app: TauApp) -> None: # get their own bubbles below. text_bubble: Bubble | None = None tool_bubbles: dict[str, Bubble] = {} + interrupted = False async with app.agent.run(app.model, app.messages, params=STREAM_PARAMS) as stream: - async for event in stream: - if isinstance(event, ai.events.TextDelta): - if text_bubble is None: - text_bubble = app.transcript.add_bubble("assistant") - following = app.transcript.at_bottom - text_bubble.append(event.chunk) - if following: - app.transcript.scroll_end(animate=False) - elif isinstance(event, ai.events.ToolEnd): - tc = event.tool_call - bubble = app.transcript.add_bubble( - "tool", _format_tool_call(tc.tool_name, tc.tool_args) - ) - tool_bubbles[tc.tool_call_id] = bubble - # The next text chunk should start a fresh bubble - # so tool output and prose stay separated. - text_bubble = None - elif isinstance(event, ai.events.ToolCallResult): - for part in event.results: - tb: Bubble | None = tool_bubbles.get(part.tool_call_id) - if tb is None: - tb = app.transcript.add_bubble( - "tool", - f"→ {part.tool_name}(?)", - ) - tb.append(_format_tool_result(part.result, part.is_error)) - elif isinstance(event, ai.events.HookEvent): - app.on_hook_event(event.hook) + try: + async for event in stream: + if isinstance(event, ai.events.TextDelta): + if text_bubble is None: + text_bubble = app.transcript.add_bubble("assistant") + following = app.transcript.at_bottom + text_bubble.append(event.chunk) + if following: + app.transcript.scroll_end(animate=False) + elif isinstance(event, ai.events.ToolEnd): + tc = event.tool_call + bubble = app.transcript.add_bubble( + "tool", _format_tool_call(tc.tool_name, tc.tool_args) + ) + tool_bubbles[tc.tool_call_id] = bubble + # The next text chunk should start a fresh bubble + # so tool output and prose stay separated. + text_bubble = None + elif isinstance(event, ai.events.ToolCallResult): + for part in event.results: + tb: Bubble | None = tool_bubbles.get(part.tool_call_id) + if tb is None: + tb = app.transcript.add_bubble( + "tool", + f"→ {part.tool_name}(?)", + ) + tb.append(_format_tool_result(part.result, part.is_error)) + elif isinstance(event, ai.events.HookEvent): + app.on_hook_event(event.hook) + except asyncio.CancelledError: + interrupted = True # Persist whatever the agent added (assistant + tool turns) - # so the next turn sees the full history. + # so the next turn sees the full history. On interruption we + # still save the partial state so context isn't lost. app.messages = list(stream.messages) app._save_messages() - # Accumulate token usage from new messages. app._refresh_usage() + if interrupted: + raise asyncio.CancelledError def _format_tool_call(name: str, raw_args: str) -> str: @@ -499,6 +509,7 @@ class TauApp(textual.app.App[None]): BINDINGS = [ textual.binding.Binding("ctrl+c", "quit", "quit", priority=True), textual.binding.Binding("ctrl+d", "quit", "quit", priority=True), + textual.binding.Binding("escape", "interrupt", "interrupt", priority=True), ] TITLE = "tau" @@ -532,6 +543,7 @@ def __init__( self._hook_queue: list[ai.messages.HookPart[Any]] = [] self._active_hook: ai.messages.HookPart[Any] | None = None self._approval = ApprovalTracker() + self._turn_worker: textual.worker.Worker[None] | None = None # Session history — every message is persisted to a JSONL file. self._resume_path = resume_path @@ -671,11 +683,21 @@ async def on_composer_submitted(self, event: Composer.Submitted) -> None: @textual.work(exclusive=True, group="turn") async def run_turn(self) -> None: + self._turn_worker = textual.worker.get_current_worker() try: await chat_loop(self) finally: + self._turn_worker = None self._set_busy(False) + def action_interrupt(self) -> None: + """Cancel the running turn on ESC.""" + if self._turn_worker is not None: + self._turn_worker.cancel() + # Dismiss any pending approval prompt and clear the queue. + self._hook_queue.clear() + self._dismiss_active_prompt() + # ------------------------------------------------------------------ # Hook plumbing # ------------------------------------------------------------------ From 253dbc6a300e67fc05b379c6577675be815bce82 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Wed, 13 May 2026 10:43:08 -0700 Subject: [PATCH 19/73] [tau] Remove ctrl+d quit binding Co-authored-by: anthropic/claude-opus-4.6, via tau --- examples/tau-agent/tau.py | 1 - 1 file changed, 1 deletion(-) diff --git a/examples/tau-agent/tau.py b/examples/tau-agent/tau.py index bd43f3f2..665a1b8f 100644 --- a/examples/tau-agent/tau.py +++ b/examples/tau-agent/tau.py @@ -508,7 +508,6 @@ class TauApp(textual.app.App[None]): BINDINGS = [ textual.binding.Binding("ctrl+c", "quit", "quit", priority=True), - textual.binding.Binding("ctrl+d", "quit", "quit", priority=True), textual.binding.Binding("escape", "interrupt", "interrupt", priority=True), ] From 72ac6c84b8cf5c1c0e3bbe61596acfaa6eb7b0a9 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Wed, 13 May 2026 10:54:52 -0700 Subject: [PATCH 20/73] [tau] Consistent scroll-follow behavior for all event types Check at_bottom once per event before any mutation. Tool calls, tool results, and new bubbles all respect the follow state now, so scrolled-up users aren't yanked down. Co-authored-by: anthropic/claude-opus-4.6, via tau --- examples/tau-agent/tau.py | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/examples/tau-agent/tau.py b/examples/tau-agent/tau.py index 665a1b8f..db6c01fb 100644 --- a/examples/tau-agent/tau.py +++ b/examples/tau-agent/tau.py @@ -109,17 +109,19 @@ async def _run_turn(app: TauApp) -> None: async with app.agent.run(app.model, app.messages, params=STREAM_PARAMS) as stream: try: async for event in stream: + following = app.transcript.at_bottom if isinstance(event, ai.events.TextDelta): if text_bubble is None: - text_bubble = app.transcript.add_bubble("assistant") - following = app.transcript.at_bottom + text_bubble = app.transcript.add_bubble( + "assistant", auto_scroll=False + ) text_bubble.append(event.chunk) - if following: - app.transcript.scroll_end(animate=False) elif isinstance(event, ai.events.ToolEnd): tc = event.tool_call bubble = app.transcript.add_bubble( - "tool", _format_tool_call(tc.tool_name, tc.tool_args) + "tool", + _format_tool_call(tc.tool_name, tc.tool_args), + auto_scroll=False, ) tool_bubbles[tc.tool_call_id] = bubble # The next text chunk should start a fresh bubble @@ -132,10 +134,13 @@ async def _run_turn(app: TauApp) -> None: tb = app.transcript.add_bubble( "tool", f"→ {part.tool_name}(?)", + auto_scroll=False, ) tb.append(_format_tool_result(part.result, part.is_error)) elif isinstance(event, ai.events.HookEvent): app.on_hook_event(event.hook) + if following: + app.transcript.scroll_end(animate=False) except asyncio.CancelledError: interrupted = True # Persist whatever the agent added (assistant + tool turns) @@ -237,10 +242,13 @@ class Transcript(textual.containers.VerticalScroll): } """ - def add_bubble(self, role: str, text: str = "") -> Bubble: + def add_bubble( + self, role: str, text: str = "", *, auto_scroll: bool = True + ) -> Bubble: bubble = Bubble(role, text) self.mount(bubble) - self.scroll_end(animate=False) + if auto_scroll: + self.scroll_end(animate=False) return bubble @property From 3650b203ccb8140e8cc4952a301599fc10900f1c Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Wed, 13 May 2026 11:02:41 -0700 Subject: [PATCH 21/73] [tau] Make bash tool a StreamingTextTool Bash now yields output lines as they arrive from the subprocess, emitting PartialToolCallResult events. The ConcatAggregator concatenates all chunks into the final tool result for the model. tau.py doesn't handle the streaming events yet. Co-authored-by: anthropic/claude-opus-4.6, via tau --- examples/tau-agent/tools.py | 36 ++++++++++++++++++++++++++---------- 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/examples/tau-agent/tools.py b/examples/tau-agent/tools.py index e2ade215..07e4433b 100644 --- a/examples/tau-agent/tools.py +++ b/examples/tau-agent/tools.py @@ -363,7 +363,7 @@ async def edit(path: str, edits: list[TextEdit]) -> str: @ai.tool(require_approval=True) -async def bash(command: str, timeout: float | None = None) -> str: +async def bash(command: str, timeout: float | None = None) -> ai.StreamingTextTool: """Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to the last 2000 @@ -375,32 +375,48 @@ async def bash(command: str, timeout: float | None = None) -> str: stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT, ) + assert proc.stdout is not None + buf: list[str] = [] + timed_out = False + deadline = ( + asyncio.get_event_loop().time() + timeout if timeout is not None else None + ) try: - out_b, _ = await asyncio.wait_for(proc.communicate(), timeout=timeout) + async for raw in proc.stdout: + if deadline is not None and asyncio.get_event_loop().time() > deadline: + raise TimeoutError + line = raw.decode("utf-8", errors="replace") + buf.append(line) + yield line except TimeoutError: + timed_out = True proc.kill() await proc.wait() - return f"[Timed out after {timeout}s]" + yield f"\n[Timed out after {timeout}s]\n" + return + + await proc.wait() - text = out_b.decode("utf-8", errors="replace") + # After streaming, check if we need to append metadata. + text = "".join(buf) tr = truncate_tail(text) - out = tr.content if tr.truncated: if tr.truncated_by == "lines": - out += ( + yield ( f"\n\n[Truncated: showing last {tr.output_lines} of " f"{tr.total_lines} lines]" ) else: - out += ( + yield ( f"\n\n[Truncated: showing last {format_size(tr.output_bytes)} " f"of {format_size(tr.total_bytes)}]" ) - if proc.returncode and proc.returncode != 0: - out += f"\n\n[Exit code: {proc.returncode}]" + if not timed_out and proc.returncode and proc.returncode != 0: + yield f"\n\n[Exit code: {proc.returncode}]" - return out or "[no output]" + if not buf and not timed_out: + yield "[no output]" # --------------------------------------------------------------------------- From 65563e1a97d80eb4fe98729ec43ff645fd173fb6 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Fri, 15 May 2026 14:20:14 -0700 Subject: [PATCH 22/73] [tau] Unwrap ExceptionGroup errors for readable error bubbles Co-authored-by: anthropic/claude-opus-4.6, via tau --- examples/tau-agent/tau.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/examples/tau-agent/tau.py b/examples/tau-agent/tau.py index db6c01fb..fb32db85 100644 --- a/examples/tau-agent/tau.py +++ b/examples/tau-agent/tau.py @@ -96,7 +96,7 @@ async def chat_loop(app: TauApp) -> None: app.transcript.add_bubble("system", "interrupted") raise except Exception as exc: # noqa: BLE001 — surface in the UI - app.transcript.add_bubble("system", f"error: {exc}") + app.transcript.add_bubble("system", f"error: {_flatten_error(exc)}") async def _run_turn(app: TauApp) -> None: @@ -153,6 +153,17 @@ async def _run_turn(app: TauApp) -> None: raise asyncio.CancelledError +def _flatten_error(exc: BaseException) -> str: + """Unwrap ExceptionGroups and chained exceptions into a readable string.""" + if isinstance(exc, ExceptionGroup): + parts = [_flatten_error(e) for e in exc.exceptions] + return "; ".join(parts) + msg = str(exc) + if exc.__cause__ is not None: + msg += f" (caused by {_flatten_error(exc.__cause__)})" + return f"{type(exc).__name__}: {msg}" if msg else type(exc).__name__ + + def _format_tool_call(name: str, raw_args: str) -> str: try: args = json.loads(raw_args) if raw_args else {} From 0d33a9f4fcdf41c71101cc0fdcef69e953442286 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Fri, 15 May 2026 14:22:38 -0700 Subject: [PATCH 23/73] [tau] Only send gateway providerOptions when using gateway provider Fixes TypeError when using anthropic: or openai: provider prefixes directly, where providerOptions is not a valid parameter. Co-authored-by: anthropic/claude-opus-4.6, via tau --- examples/tau-agent/tau.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/examples/tau-agent/tau.py b/examples/tau-agent/tau.py index fb32db85..2a57e8e4 100644 --- a/examples/tau-agent/tau.py +++ b/examples/tau-agent/tau.py @@ -42,9 +42,12 @@ _raw_model = os.environ.get("TAU_MODEL", "gateway:anthropic/claude-opus-4.6") MODEL_ID = _raw_model if ":" in _raw_model else f"gateway:{_raw_model}" -STREAM_PARAMS: dict[str, Any] = { - "providerOptions": {"gateway": {"caching": "auto"}}, -} +# Only send gateway-specific options when routing through the gateway. +STREAM_PARAMS: dict[str, Any] | None = ( + {"providerOptions": {"gateway": {"caching": "auto"}}} + if MODEL_ID.startswith("gateway:") + else None +) _ADVERTISE = os.environ.get("TAU_ADVERTISE", "") == "1" From ed4274516725292b83f218c16402039c85005a89 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Fri, 15 May 2026 14:47:41 -0700 Subject: [PATCH 24/73] [tau] Display thinking/reasoning blocks in dim italic bubbles Co-authored-by: anthropic/claude-opus-4.6, via tau --- examples/tau-agent/tau.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/examples/tau-agent/tau.py b/examples/tau-agent/tau.py index 2a57e8e4..2e425740 100644 --- a/examples/tau-agent/tau.py +++ b/examples/tau-agent/tau.py @@ -107,13 +107,22 @@ async def _run_turn(app: TauApp) -> None: # One assistant bubble per turn for streamed text; tool calls # get their own bubbles below. text_bubble: Bubble | None = None + thinking_bubble: Bubble | None = None tool_bubbles: dict[str, Bubble] = {} interrupted = False async with app.agent.run(app.model, app.messages, params=STREAM_PARAMS) as stream: try: async for event in stream: following = app.transcript.at_bottom - if isinstance(event, ai.events.TextDelta): + if isinstance(event, ai.events.ReasoningDelta): + if thinking_bubble is None: + thinking_bubble = app.transcript.add_bubble( + "thinking", auto_scroll=False + ) + thinking_bubble.append(event.chunk) + elif isinstance(event, ai.events.ReasoningEnd): + thinking_bubble = None + elif isinstance(event, ai.events.TextDelta): if text_bubble is None: text_bubble = app.transcript.add_bubble( "assistant", auto_scroll=False @@ -222,6 +231,10 @@ class Bubble(textual.widgets.Static): Bubble.tool { color: $text-muted; } + Bubble.thinking { + color: $text-muted; + text-style: dim italic; + } """ def __init__(self, role: str, initial: str = "") -> None: From 505147053bc7dea8cf46c5f3602d215404b2e428 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Sat, 16 May 2026 11:11:53 -0700 Subject: [PATCH 25/73] [tau] Enable adaptive thinking for Anthropic models via gateway Co-authored-by: anthropic/claude-opus-4.6, via tau --- examples/tau-agent/tau.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/examples/tau-agent/tau.py b/examples/tau-agent/tau.py index 2e425740..d45dfa34 100644 --- a/examples/tau-agent/tau.py +++ b/examples/tau-agent/tau.py @@ -44,7 +44,12 @@ # Only send gateway-specific options when routing through the gateway. STREAM_PARAMS: dict[str, Any] | None = ( - {"providerOptions": {"gateway": {"caching": "auto"}}} + { + "providerOptions": { + "gateway": {"caching": "auto"}, + "anthropic": {"thinking": {"type": "enabled", "budget_tokens": 10000}}, + } + } if MODEL_ID.startswith("gateway:") else None ) From bc0c770d9cdc041d9c355b3a342a90be9df9be12 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Sat, 16 May 2026 14:41:35 -0700 Subject: [PATCH 26/73] [tau] Fix crash when tool kwargs contain Pydantic models _short_value called json.dumps on hook metadata kwargs which may contain validated Pydantic model instances (e.g. TextEdit) instead of plain dicts. Fall back to repr() when json.dumps raises TypeError. Also add refresh(layout=True) to Bubble._redraw to ensure tool result appends trigger Textual re-layout. Co-authored-by: anthropic/claude-opus-4.6, via tau --- examples/tau-agent/tau.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/examples/tau-agent/tau.py b/examples/tau-agent/tau.py index d45dfa34..b7c9f6c0 100644 --- a/examples/tau-agent/tau.py +++ b/examples/tau-agent/tau.py @@ -146,14 +146,8 @@ async def _run_turn(app: TauApp) -> None: text_bubble = None elif isinstance(event, ai.events.ToolCallResult): for part in event.results: - tb: Bubble | None = tool_bubbles.get(part.tool_call_id) - if tb is None: - tb = app.transcript.add_bubble( - "tool", - f"→ {part.tool_name}(?)", - auto_scroll=False, - ) - tb.append(_format_tool_result(part.result, part.is_error)) + preview = _format_tool_result(part.result, part.is_error) + app.transcript.add_bubble("tool", preview, auto_scroll=False) elif isinstance(event, ai.events.HookEvent): app.on_hook_event(event.hook) if following: @@ -191,7 +185,13 @@ def _format_tool_call(name: str, raw_args: str) -> str: def _short_value(v: Any) -> str: - s = json.dumps(v, ensure_ascii=False) if not isinstance(v, str) else repr(v) + if isinstance(v, str): + s = repr(v) + else: + try: + s = json.dumps(v, ensure_ascii=False) + except TypeError: + s = repr(v) if len(s) > 80: s = s[:77] + "…" return s From 3da4d2202bb4d2e8241022f670fed08f0c3b22ed Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Sat, 16 May 2026 15:22:30 -0700 Subject: [PATCH 27/73] [tau] Default auto_scroll=False for add_bubble Only scroll to bottom when explicitly requested (user input) or when the follow-scroll check in _run_turn determines the user is already at the bottom. Prevents yanking scrolled-up users down on tool results, approval messages, and system bubbles. Co-authored-by: anthropic/claude-opus-4.6, via tau --- examples/tau-agent/tau.py | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/examples/tau-agent/tau.py b/examples/tau-agent/tau.py index b7c9f6c0..ed140b1c 100644 --- a/examples/tau-agent/tau.py +++ b/examples/tau-agent/tau.py @@ -121,24 +121,19 @@ async def _run_turn(app: TauApp) -> None: following = app.transcript.at_bottom if isinstance(event, ai.events.ReasoningDelta): if thinking_bubble is None: - thinking_bubble = app.transcript.add_bubble( - "thinking", auto_scroll=False - ) + thinking_bubble = app.transcript.add_bubble("thinking") thinking_bubble.append(event.chunk) elif isinstance(event, ai.events.ReasoningEnd): thinking_bubble = None elif isinstance(event, ai.events.TextDelta): if text_bubble is None: - text_bubble = app.transcript.add_bubble( - "assistant", auto_scroll=False - ) + text_bubble = app.transcript.add_bubble("assistant") text_bubble.append(event.chunk) elif isinstance(event, ai.events.ToolEnd): tc = event.tool_call bubble = app.transcript.add_bubble( "tool", _format_tool_call(tc.tool_name, tc.tool_args), - auto_scroll=False, ) tool_bubbles[tc.tool_call_id] = bubble # The next text chunk should start a fresh bubble @@ -147,7 +142,7 @@ async def _run_turn(app: TauApp) -> None: elif isinstance(event, ai.events.ToolCallResult): for part in event.results: preview = _format_tool_result(part.result, part.is_error) - app.transcript.add_bubble("tool", preview, auto_scroll=False) + app.transcript.add_bubble("tool", preview) elif isinstance(event, ai.events.HookEvent): app.on_hook_event(event.hook) if following: @@ -275,7 +270,7 @@ class Transcript(textual.containers.VerticalScroll): """ def add_bubble( - self, role: str, text: str = "", *, auto_scroll: bool = True + self, role: str, text: str = "", *, auto_scroll: bool = False ) -> Bubble: bubble = Bubble(role, text) self.mount(bubble) @@ -604,6 +599,7 @@ def on_mount(self) -> None: self._restore_session(self._resume_path) else: self._start_new_session() + self.transcript.scroll_end(animate=False) self.query_one("#composer", Composer).focus() # ------------------------------------------------------------------ @@ -709,7 +705,7 @@ async def on_composer_submitted(self, event: Composer.Submitted) -> None: if not text: return - self.transcript.add_bubble("user", text) + self.transcript.add_bubble("user", text, auto_scroll=True) # All submissions enter the queue; ``run_turn`` is the sole # consumer. The user bubble shows up immediately so the message # feels sent even when it won't reach the model until the From a094bbb70fb446939fe448722a3d8769c0156911 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Sat, 16 May 2026 16:37:52 -0700 Subject: [PATCH 28/73] [tau] Move ApprovalTracker to agent-loop section of tau.py Pure code-move: the tool-category constants, _tool_path helper, and ApprovalTracker class are agent-loop concerns, not UI widgets. Moved them from between Composer and HookPrompt up to a new "Approval tracking" subsection right after the formatting helpers. Co-authored-by: anthropic/claude-opus-4.6, via tau --- examples/tau-agent/tau.py | 186 +++++++++++++++++++------------------- 1 file changed, 95 insertions(+), 91 deletions(-) diff --git a/examples/tau-agent/tau.py b/examples/tau-agent/tau.py index ed140b1c..530f825c 100644 --- a/examples/tau-agent/tau.py +++ b/examples/tau-agent/tau.py @@ -204,6 +204,101 @@ def _format_tool_result(result: Any, is_error: bool) -> str: return f"\n {marker} {indented}" +# --------------------------------------------------------------------------- +# Approval tracking +# --------------------------------------------------------------------------- + +# Tools grouped by category for approval purposes. +_READ_TOOLS = frozenset({"read", "grep", "find", "ls"}) +_WRITE_TOOLS = frozenset({"write", "edit"}) +_FILE_TOOLS = _READ_TOOLS | _WRITE_TOOLS + + +def _tool_path(hook: ai.messages.HookPart[Any]) -> pathlib.Path | None: + """Extract and resolve the path argument from a file-tool hook.""" + kwargs = hook.metadata.get("kwargs", {}) or {} + raw = kwargs.get("path") + if raw is None: + return None + return pathlib.Path(raw).expanduser().resolve() + + +class ApprovalTracker: + """Session-scoped approval state for tool hooks. + + Tracks "always approve" decisions so subsequent identical commands + (or all commands) can be auto-resolved without prompting. + + File I/O tools are auto-approved when the target path is under the + working directory. Paths outside cwd require a prompt; one of the + options is to permanently allow a directory for reads or writes. + """ + + def __init__(self) -> None: + self._cwd = pathlib.Path.cwd().resolve() + self._approve_all = False + self._approved_commands: set[str] = set() + # Extra directory trees approved per category. + self._approved_read_dirs: set[pathlib.Path] = set() + self._approved_write_dirs: set[pathlib.Path] = set() + + def _path_ok(self, tool: str, path: pathlib.Path | None) -> bool: + """Check if *path* is in an approved directory for *tool*.""" + if path is None: + # Tools like grep/find/ls default to cwd when path is None. + return True + # Always allow anything under cwd. + try: + path.relative_to(self._cwd) + return True + except ValueError: + pass + # Check extra approved dirs. + dirs = ( + self._approved_read_dirs + if tool in _READ_TOOLS + else self._approved_write_dirs + ) + return any(path == d or d in path.parents for d in dirs) + + def check(self, hook: ai.messages.HookPart[Any]) -> bool | None: + """Return True to auto-approve, False to auto-deny, None to prompt.""" + if self._approve_all: + return True + tool = hook.metadata.get("tool", "") + kwargs = hook.metadata.get("kwargs", {}) or {} + if tool in _FILE_TOOLS: + return True if self._path_ok(tool, _tool_path(hook)) else None + if tool == "bash": + cmd = kwargs.get("command", "") + if cmd in self._approved_commands: + return True + return None + + def approve_command(self, hook: ai.messages.HookPart[Any]) -> None: + """Remember to always approve this exact bash command.""" + kwargs = hook.metadata.get("kwargs", {}) or {} + cmd = kwargs.get("command", "") + if cmd: + self._approved_commands.add(cmd) + + def approve_directory(self, hook: ai.messages.HookPart[Any]) -> None: + """Allow all future operations in this path's directory.""" + tool = hook.metadata.get("tool", "") + path = _tool_path(hook) + if path is None: + return + directory = path if path.is_dir() else path.parent + if tool in _READ_TOOLS: + self._approved_read_dirs.add(directory) + elif tool in _WRITE_TOOLS: + self._approved_write_dirs.add(directory) + + def approve_all(self) -> None: + """Auto-approve every future tool call this session.""" + self._approve_all = True + + # --------------------------------------------------------------------------- # Widgets # --------------------------------------------------------------------------- @@ -337,97 +432,6 @@ def refresh_height(self) -> None: self.styles.height = n + 2 -# Tools grouped by category for approval purposes. -_READ_TOOLS = frozenset({"read", "grep", "find", "ls"}) -_WRITE_TOOLS = frozenset({"write", "edit"}) -_FILE_TOOLS = _READ_TOOLS | _WRITE_TOOLS - - -def _tool_path(hook: ai.messages.HookPart[Any]) -> pathlib.Path | None: - """Extract and resolve the path argument from a file-tool hook.""" - kwargs = hook.metadata.get("kwargs", {}) or {} - raw = kwargs.get("path") - if raw is None: - return None - return pathlib.Path(raw).expanduser().resolve() - - -class ApprovalTracker: - """Session-scoped approval state for tool hooks. - - Tracks "always approve" decisions so subsequent identical commands - (or all commands) can be auto-resolved without prompting. - - File I/O tools are auto-approved when the target path is under the - working directory. Paths outside cwd require a prompt; one of the - options is to permanently allow a directory for reads or writes. - """ - - def __init__(self) -> None: - self._cwd = pathlib.Path.cwd().resolve() - self._approve_all = False - self._approved_commands: set[str] = set() - # Extra directory trees approved per category. - self._approved_read_dirs: set[pathlib.Path] = set() - self._approved_write_dirs: set[pathlib.Path] = set() - - def _path_ok(self, tool: str, path: pathlib.Path | None) -> bool: - """Check if *path* is in an approved directory for *tool*.""" - if path is None: - # Tools like grep/find/ls default to cwd when path is None. - return True - # Always allow anything under cwd. - try: - path.relative_to(self._cwd) - return True - except ValueError: - pass - # Check extra approved dirs. - dirs = ( - self._approved_read_dirs - if tool in _READ_TOOLS - else self._approved_write_dirs - ) - return any(path == d or d in path.parents for d in dirs) - - def check(self, hook: ai.messages.HookPart[Any]) -> bool | None: - """Return True to auto-approve, False to auto-deny, None to prompt.""" - if self._approve_all: - return True - tool = hook.metadata.get("tool", "") - kwargs = hook.metadata.get("kwargs", {}) or {} - if tool in _FILE_TOOLS: - return True if self._path_ok(tool, _tool_path(hook)) else None - if tool == "bash": - cmd = kwargs.get("command", "") - if cmd in self._approved_commands: - return True - return None - - def approve_command(self, hook: ai.messages.HookPart[Any]) -> None: - """Remember to always approve this exact bash command.""" - kwargs = hook.metadata.get("kwargs", {}) or {} - cmd = kwargs.get("command", "") - if cmd: - self._approved_commands.add(cmd) - - def approve_directory(self, hook: ai.messages.HookPart[Any]) -> None: - """Allow all future operations in this path's directory.""" - tool = hook.metadata.get("tool", "") - path = _tool_path(hook) - if path is None: - return - directory = path if path.is_dir() else path.parent - if tool in _READ_TOOLS: - self._approved_read_dirs.add(directory) - elif tool in _WRITE_TOOLS: - self._approved_write_dirs.add(directory) - - def approve_all(self) -> None: - """Auto-approve every future tool call this session.""" - self._approve_all = True - - class HookPrompt(textual.widgets.Static): """Approval prompt for a pending tool-approval hook. From 2431939f2c575c67cc325388071660645e06746f Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Tue, 19 May 2026 14:41:19 -0700 Subject: [PATCH 29/73] [tau] Update uv.lock? --- examples/tau-agent/.gitignore | 5 ----- examples/tau-agent/uv.lock | 12 +++++++++--- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/examples/tau-agent/.gitignore b/examples/tau-agent/.gitignore index d2ad3c26..115444d7 100644 --- a/examples/tau-agent/.gitignore +++ b/examples/tau-agent/.gitignore @@ -1,7 +1,3 @@ -<<<<<<< HEAD -.tau/ -||||||| parent of 9884d0a ([tau] Add session history with persist/resume support) -======= # Python __pycache__/ *.py[cod] @@ -15,4 +11,3 @@ dist/ # Tau session history .tau/ ->>>>>>> 9884d0a ([tau] Add session history with persist/resume support) diff --git a/examples/tau-agent/uv.lock b/examples/tau-agent/uv.lock index 7fdf026d..629f722a 100644 --- a/examples/tau-agent/uv.lock +++ b/examples/tau-agent/uv.lock @@ -29,14 +29,20 @@ provides-extras = ["anthropic", "openai"] dev = [ { name = "anthropic", specifier = ">=0.83.0" }, { name = "async-solipsism", specifier = ">=0.9" }, - { name = "mypy", specifier = ">=1.11" }, + { name = "mypy", specifier = "~=2.1.0" }, { name = "openai", specifier = ">=2.14.0" }, - { name = "pyright", specifier = ">=1.1.408" }, { name = "pytest", specifier = ">=8.0" }, { name = "pytest-asyncio", specifier = ">=0.24" }, { name = "python-dotenv", specifier = ">=1.2.1" }, { name = "rich", specifier = ">=14.2.0" }, - { name = "ruff", specifier = ">=0.8" }, + { name = "ruff", specifier = "~=0.8.0" }, + { name = "ty", specifier = "~=0.0.37" }, +] +examples = [ + { name = "fastapi", specifier = ">=0.136.1" }, + { name = "temporalio", specifier = ">=1.27.2" }, + { name = "textual", specifier = ">=8.2.6" }, + { name = "websockets", specifier = ">=16.0" }, ] [[package]] From 7e841c4bc150666b9f84e6ef6b5e50e37890f4ab Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Tue, 19 May 2026 16:40:58 -0700 Subject: [PATCH 30/73] [tau] Move tau-agent to a proper Python package Move top-level modules into a tau/ package: - tau.py -> tau/app.py (with main() entry point) - tools.py -> tau/tools.py - session.py -> tau/session.py Add tau/__init__.py and tau/__main__.py so the app can be invoked as `python -m tau` or via the `tau` script entry point. Add build-system (hatchling) and project.scripts to pyproject.toml. Co-authored-by: anthropic/claude-opus-4.6, via tau --- examples/tau-agent/README.md | 2 +- examples/tau-agent/pyproject.toml | 10 +++++++ examples/tau-agent/tau/__init__.py | 1 + examples/tau-agent/tau/__main__.py | 5 ++++ examples/tau-agent/{tau.py => tau/app.py} | 35 ++++++++++++----------- examples/tau-agent/{ => tau}/session.py | 0 examples/tau-agent/{ => tau}/tools.py | 0 examples/tau-agent/uv.lock | 2 +- 8 files changed, 37 insertions(+), 18 deletions(-) create mode 100644 examples/tau-agent/tau/__init__.py create mode 100644 examples/tau-agent/tau/__main__.py rename examples/tau-agent/{tau.py => tau/app.py} (97%) rename examples/tau-agent/{ => tau}/session.py (100%) rename examples/tau-agent/{ => tau}/tools.py (100%) diff --git a/examples/tau-agent/README.md b/examples/tau-agent/README.md index f60651cb..22d70039 100644 --- a/examples/tau-agent/README.md +++ b/examples/tau-agent/README.md @@ -28,7 +28,7 @@ uv sync ## Running ```bash -uv run python tau.py +uv run tau ``` Type a message, hit enter. `ctrl+c` to quit. diff --git a/examples/tau-agent/pyproject.toml b/examples/tau-agent/pyproject.toml index eab269e6..333b2419 100644 --- a/examples/tau-agent/pyproject.toml +++ b/examples/tau-agent/pyproject.toml @@ -8,6 +8,16 @@ dependencies = [ "textual>=3.0", ] +[project.scripts] +tau = "tau.app:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["tau"] + [tool.uv.sources] ai = { path = "../..", editable = true } diff --git a/examples/tau-agent/tau/__init__.py b/examples/tau-agent/tau/__init__.py new file mode 100644 index 00000000..4056ccaf --- /dev/null +++ b/examples/tau-agent/tau/__init__.py @@ -0,0 +1 @@ +"""tau — a coding-agent chat bot built on the `ai` library and Textual.""" diff --git a/examples/tau-agent/tau/__main__.py b/examples/tau-agent/tau/__main__.py new file mode 100644 index 00000000..6abfa365 --- /dev/null +++ b/examples/tau-agent/tau/__main__.py @@ -0,0 +1,5 @@ +"""Entry point for ``python -m tau``.""" + +from tau.app import main + +main() diff --git a/examples/tau-agent/tau.py b/examples/tau-agent/tau/app.py similarity index 97% rename from examples/tau-agent/tau.py rename to examples/tau-agent/tau/app.py index 530f825c..da0fa159 100644 --- a/examples/tau-agent/tau.py +++ b/examples/tau-agent/tau/app.py @@ -7,10 +7,10 @@ Sessions are persisted to ``.tau/sessions/`` as JSONL files and can be resumed: - uv run python tau.py # new session - uv run python tau.py --resume # resume most recent session - uv run python tau.py --session ID # resume a specific session - uv run python tau.py --list # list saved sessions + python -m tau # new session + python -m tau --resume # resume most recent session + python -m tau --session ID # resume a specific session + python -m tau --list # list saved sessions """ from __future__ import annotations @@ -36,8 +36,7 @@ import textual.widgets import textual.worker -import session as session_ -import tools as tools_ +from tau import session, tools _raw_model = os.environ.get("TAU_MODEL", "gateway:anthropic/claude-opus-4.6") MODEL_ID = _raw_model if ":" in _raw_model else f"gateway:{_raw_model}" @@ -566,7 +565,7 @@ def __init__( ) -> None: super().__init__() self.model = ai.get_model(MODEL_ID) - self.agent = ai.agent(tools=tools_.TOOLS) + self.agent = ai.agent(tools=tools.TOOLS) # The full conversation, including the system prompt. We mutate # this in place so the agent always sees the entire history. self.messages: list[ai.messages.Message] = [ai.system_message(SYSTEM_PROMPT)] @@ -611,10 +610,10 @@ def on_mount(self) -> None: # ------------------------------------------------------------------ def _start_new_session(self) -> None: - self._session_id = session_.new_session_id() - self._session_path = session_.create_session(self._session_id, MODEL_ID) + self._session_id = session.new_session_id() + self._session_path = session.create_session(self._session_id, MODEL_ID) # Persist the system message. - self._saved_count = session_.append_messages( + self._saved_count = session.append_messages( self._session_path, self.messages, after=0 ) self.transcript.add_bubble( @@ -623,7 +622,7 @@ def _start_new_session(self) -> None: ) def _restore_session(self, path: pathlib.Path) -> None: - meta, messages = session_.load_messages(path) + meta, messages = session.load_messages(path) self._session_id = meta.get("session_id", path.stem) self._session_path = path if messages: @@ -655,7 +654,7 @@ def _save_messages(self) -> None: """Append any new messages to the session JSONL file.""" if self._session_path is None: return - self._saved_count = session_.append_messages( + self._saved_count = session.append_messages( self._session_path, self.messages, after=self._saved_count ) @@ -861,7 +860,7 @@ def _parse_args() -> argparse.Namespace: def _print_sessions() -> None: - sessions = session_.list_sessions() + sessions = session.list_sessions() if not sessions: print("No saved sessions.") return @@ -874,7 +873,7 @@ def _print_sessions() -> None: print(f"{sid:<20} {model:<35} {cwd}") -if __name__ == "__main__": +def main() -> None: args = _parse_args() if args.list: @@ -884,14 +883,18 @@ def _print_sessions() -> None: resume_path: pathlib.Path | None = None if args.resume: - resume_path = session_.resolve_session(None) + resume_path = session.resolve_session(None) if resume_path is None: print("No sessions to resume.", file=sys.stderr) sys.exit(1) elif args.session: - resume_path = session_.resolve_session(args.session) + resume_path = session.resolve_session(args.session) if resume_path is None: print(f"Session not found: {args.session}", file=sys.stderr) sys.exit(1) TauApp(resume_path=resume_path).run() + + +if __name__ == "__main__": + main() diff --git a/examples/tau-agent/session.py b/examples/tau-agent/tau/session.py similarity index 100% rename from examples/tau-agent/session.py rename to examples/tau-agent/tau/session.py diff --git a/examples/tau-agent/tools.py b/examples/tau-agent/tau/tools.py similarity index 100% rename from examples/tau-agent/tools.py rename to examples/tau-agent/tau/tools.py diff --git a/examples/tau-agent/uv.lock b/examples/tau-agent/uv.lock index 629f722a..645da048 100644 --- a/examples/tau-agent/uv.lock +++ b/examples/tau-agent/uv.lock @@ -723,7 +723,7 @@ wheels = [ [[package]] name = "tau-agent" version = "0.1.0" -source = { virtual = "." } +source = { editable = "." } dependencies = [ { name = "ai" }, { name = "textual" }, From b5705429118919fe0b30dc1eb6b6faa67377248e Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Tue, 19 May 2026 16:48:03 -0700 Subject: [PATCH 31/73] [tau] Extract SessionManager from TauApp Move session state (messages, session_id, usage tracking) and persistence logic (save, restore, refresh_usage) into a SessionManager class. TauApp holds a single .session attribute instead of 6 scattered fields and 4 methods. The UI-facing _update_usage_display stays on the app since it queries the DOM. Co-authored-by: anthropic/claude-opus-4.6, via tau --- examples/tau-agent/tau/app.py | 151 +++++++++++++++++++++------------- 1 file changed, 92 insertions(+), 59 deletions(-) diff --git a/examples/tau-agent/tau/app.py b/examples/tau-agent/tau/app.py index da0fa159..c7faeabf 100644 --- a/examples/tau-agent/tau/app.py +++ b/examples/tau-agent/tau/app.py @@ -76,6 +76,76 @@ RESULT_PREVIEW_CHARS = 400 +# --------------------------------------------------------------------------- +# Session manager +# --------------------------------------------------------------------------- + + +class SessionManager: + """Owns the message list, session file, and usage bookkeeping. + + Pure data — no UI. The app reads ``.messages``, ``.session_id``, + ``.total_usage``, and ``.last_usage`` to drive the display. + """ + + def __init__(self, system_prompt: str) -> None: + self.messages: list[ai.messages.Message] = [ + ai.system_message(system_prompt) + ] + self.session_id: str = "" + self.total_usage: ai.types.usage.Usage = ai.types.usage.Usage() + self.last_usage: ai.types.usage.Usage | None = None + self._session_path: pathlib.Path | None = None + self._saved_count: int = 0 # messages already written to disk + + # -- lifecycle --------------------------------------------------------- + + def start(self, model_id: str) -> None: + """Create a new session file and persist the system message.""" + self.session_id = session.new_session_id() + self._session_path = session.create_session(self.session_id, model_id) + self._saved_count = session.append_messages( + self._session_path, self.messages, after=0 + ) + + def restore(self, path: pathlib.Path) -> dict[str, Any]: + """Load an existing session from *path*. + + Returns the metadata dict. Populates ``.messages`` and + ``.session_id``; call ``refresh_usage()`` afterwards. + """ + meta, messages = session.load_messages(path) + self.session_id = meta.get("session_id", path.stem) + self._session_path = path + if messages: + self.messages = messages + self._saved_count = len(self.messages) # already on disk + return meta + + # -- persistence ------------------------------------------------------- + + def save(self) -> None: + """Append any new messages to the session JSONL file.""" + if self._session_path is None: + return + self._saved_count = session.append_messages( + self._session_path, self.messages, after=self._saved_count + ) + + # -- usage ------------------------------------------------------------- + + def refresh_usage(self) -> None: + """Re-derive cumulative usage from all messages.""" + total = ai.types.usage.Usage() + last: ai.types.usage.Usage | None = None + for msg in self.messages: + if msg.usage is not None: + total = total + msg.usage + last = msg.usage + self.total_usage = total + self.last_usage = last + + # =========================================================================== # Agent loop — the only place that touches the `ai` library. # @@ -88,15 +158,15 @@ async def chat_loop(app: TauApp) -> None: """Drain the pending queue, running one agent turn per queued message. - Reads from ``app.pending`` and ``app.messages``; writes streamed - text into a fresh assistant bubble on ``app.transcript``. All - interaction with the ``ai`` library lives here. + Reads from ``app.pending`` and ``app.session.messages``; writes + streamed text into a fresh assistant bubble on ``app.transcript``. + All interaction with the ``ai`` library lives here. """ while app.pending: # Pop one queued message into history per turn so the model sees # a clean user → assistant → user → … sequence. - app.messages.append(ai.user_message(app.pending.pop(0))) - app._save_messages() + app.session.messages.append(ai.user_message(app.pending.pop(0))) + app.session.save() try: await _run_turn(app) except asyncio.CancelledError: @@ -114,7 +184,7 @@ async def _run_turn(app: TauApp) -> None: thinking_bubble: Bubble | None = None tool_bubbles: dict[str, Bubble] = {} interrupted = False - async with app.agent.run(app.model, app.messages, params=STREAM_PARAMS) as stream: + async with app.agent.run(app.model, app.session.messages, params=STREAM_PARAMS) as stream: try: async for event in stream: following = app.transcript.at_bottom @@ -151,9 +221,10 @@ async def _run_turn(app: TauApp) -> None: # Persist whatever the agent added (assistant + tool turns) # so the next turn sees the full history. On interruption we # still save the partial state so context isn't lost. - app.messages = list(stream.messages) - app._save_messages() - app._refresh_usage() + app.session.messages = list(stream.messages) + app.session.save() + app.session.refresh_usage() + app._update_usage_display() if interrupted: raise asyncio.CancelledError @@ -555,7 +626,7 @@ class TauApp(textual.app.App[None]): # function is meant to be readable next to the app. model: ai.Model agent: ai.Agent - messages: list[ai.messages.Message] + session: SessionManager pending: list[str] def __init__( @@ -566,9 +637,7 @@ def __init__( super().__init__() self.model = ai.get_model(MODEL_ID) self.agent = ai.agent(tools=tools.TOOLS) - # The full conversation, including the system prompt. We mutate - # this in place so the agent always sees the entire history. - self.messages: list[ai.messages.Message] = [ai.system_message(SYSTEM_PROMPT)] + self.session = SessionManager(SYSTEM_PROMPT) # User messages typed while a turn is streaming. Drained one at # a time at the end of each turn so user/assistant alternation # stays clean. @@ -581,14 +650,7 @@ def __init__( self._active_hook: ai.messages.HookPart[Any] | None = None self._approval = ApprovalTracker() self._turn_worker: textual.worker.Worker[None] | None = None - - # Session history — every message is persisted to a JSONL file. self._resume_path = resume_path - self._session_id: str = "" - self._session_path: pathlib.Path | None = None - self._saved_count: int = 0 # messages already written to disk - self._total_usage: ai.types.usage.Usage = ai.types.usage.Usage() - self._last_usage: ai.types.usage.Usage | None = None def compose(self) -> textual.app.ComposeResult: yield Transcript(id="transcript") @@ -610,26 +672,16 @@ def on_mount(self) -> None: # ------------------------------------------------------------------ def _start_new_session(self) -> None: - self._session_id = session.new_session_id() - self._session_path = session.create_session(self._session_id, MODEL_ID) - # Persist the system message. - self._saved_count = session.append_messages( - self._session_path, self.messages, after=0 - ) + self.session.start(MODEL_ID) self.transcript.add_bubble( "system", - f"connected — model: {MODEL_ID} session: {self._session_id}", + f"connected — model: {MODEL_ID} session: {self.session.session_id}", ) def _restore_session(self, path: pathlib.Path) -> None: - meta, messages = session.load_messages(path) - self._session_id = meta.get("session_id", path.stem) - self._session_path = path - if messages: - self.messages = messages - self._saved_count = len(self.messages) # already on disk + self.session.restore(path) # Replay conversation into transcript bubbles. - for msg in self.messages: + for msg in self.session.messages: if msg.role == "system": continue # don't clutter the UI with the system prompt if msg.role == "user": @@ -645,40 +697,21 @@ def _restore_session(self, path: pathlib.Path) -> None: self.transcript.add_bubble("tool", preview) self.transcript.add_bubble( "system", - f"resumed session {self._session_id} " - f"({len(self.messages) - 1} messages) — model: {MODEL_ID}", - ) - self._refresh_usage() - - def _save_messages(self) -> None: - """Append any new messages to the session JSONL file.""" - if self._session_path is None: - return - self._saved_count = session.append_messages( - self._session_path, self.messages, after=self._saved_count + f"resumed session {self.session.session_id} " + f"({len(self.session.messages) - 1} messages) — model: {MODEL_ID}", ) - - def _refresh_usage(self) -> None: - """Re-derive cumulative usage from all messages.""" - total = ai.types.usage.Usage() - last_usage: ai.types.usage.Usage | None = None - for msg in self.messages: - if msg.usage is not None: - total = total + msg.usage - last_usage = msg.usage - self._total_usage = total - self._last_usage = last_usage + self.session.refresh_usage() self._update_usage_display() def _update_usage_display(self) -> None: """Show cumulative token usage in the footer bar.""" - u = self._total_usage + u = self.session.total_usage if u.total_tokens == 0: return parts: list[str] = [] # Approximate current context size: last turn's in + out. - if self._last_usage is not None: - ctx = self._last_usage.input_tokens + self._last_usage.output_tokens + if self.session.last_usage is not None: + ctx = self.session.last_usage.input_tokens + self.session.last_usage.output_tokens parts.append(f"ctx: ~{ctx:,}") # input_tokens includes cache-read; subtract to show uncached. uncached_in = u.input_tokens - (u.cache_read_tokens or 0) From 5136332b914352c7e1217ffe9f57827bf5af6772 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Tue, 19 May 2026 16:51:55 -0700 Subject: [PATCH 32/73] [tau] Remove stale comment about ai library usage Co-authored-by: anthropic/claude-opus-4.6, via tau --- examples/tau-agent/tau/app.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/examples/tau-agent/tau/app.py b/examples/tau-agent/tau/app.py index c7faeabf..a223fcb7 100644 --- a/examples/tau-agent/tau/app.py +++ b/examples/tau-agent/tau/app.py @@ -146,13 +146,9 @@ def refresh_usage(self) -> None: self.last_usage = last -# =========================================================================== -# Agent loop — the only place that touches the `ai` library. -# -# Everything below this function is plain Textual widgets and app plumbing. -# Read this function to understand what tau does; read the rest to -# understand how the TUI renders it. -# =========================================================================== +# --------------------------------------------------------------------------- +# Agent loop +# --------------------------------------------------------------------------- async def chat_loop(app: TauApp) -> None: From 09762a05e66b78100e44ec3f443c80fb715af619 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Tue, 19 May 2026 17:08:58 -0700 Subject: [PATCH 33/73] [tau] Move bubble management out of the agent loop into TauApp methods The agent loop (_run_turn) no longer touches the widget tree directly. Instead it calls app methods: append_thinking, append_text, show_tool_call, show_tool_result, show_system, follow_scroll. Bubble lifecycle (lazy creation, reset between turns) lives on TauApp where it belongs. Co-authored-by: anthropic/claude-opus-4.6, via tau --- examples/tau-agent/tau/app.py | 103 +++++++++++++++++++++------------- 1 file changed, 63 insertions(+), 40 deletions(-) diff --git a/examples/tau-agent/tau/app.py b/examples/tau-agent/tau/app.py index a223fcb7..5896d436 100644 --- a/examples/tau-agent/tau/app.py +++ b/examples/tau-agent/tau/app.py @@ -154,9 +154,9 @@ def refresh_usage(self) -> None: async def chat_loop(app: TauApp) -> None: """Drain the pending queue, running one agent turn per queued message. - Reads from ``app.pending`` and ``app.session.messages``; writes - streamed text into a fresh assistant bubble on ``app.transcript``. - All interaction with the ``ai`` library lives here. + Reads from ``app.pending`` and ``app.session.messages``; dispatches + streamed events to app methods for rendering. All interaction with + the ``ai`` library lives here. """ while app.pending: # Pop one queued message into history per turn so the model sees @@ -166,52 +166,31 @@ async def chat_loop(app: TauApp) -> None: try: await _run_turn(app) except asyncio.CancelledError: - app.transcript.add_bubble("system", "interrupted") + app.show_system("interrupted") raise except Exception as exc: # noqa: BLE001 — surface in the UI - app.transcript.add_bubble("system", f"error: {_flatten_error(exc)}") + app.show_system(f"error: {_flatten_error(exc)}") async def _run_turn(app: TauApp) -> None: - """Execute a single agent turn, streaming events into the transcript.""" - # One assistant bubble per turn for streamed text; tool calls - # get their own bubbles below. - text_bubble: Bubble | None = None - thinking_bubble: Bubble | None = None - tool_bubbles: dict[str, Bubble] = {} + """Execute a single agent turn, dispatching events to the app.""" interrupted = False async with app.agent.run(app.model, app.session.messages, params=STREAM_PARAMS) as stream: try: async for event in stream: - following = app.transcript.at_bottom if isinstance(event, ai.events.ReasoningDelta): - if thinking_bubble is None: - thinking_bubble = app.transcript.add_bubble("thinking") - thinking_bubble.append(event.chunk) - elif isinstance(event, ai.events.ReasoningEnd): - thinking_bubble = None + app.append_thinking(event.chunk) elif isinstance(event, ai.events.TextDelta): - if text_bubble is None: - text_bubble = app.transcript.add_bubble("assistant") - text_bubble.append(event.chunk) + app.append_text(event.chunk) elif isinstance(event, ai.events.ToolEnd): tc = event.tool_call - bubble = app.transcript.add_bubble( - "tool", - _format_tool_call(tc.tool_name, tc.tool_args), - ) - tool_bubbles[tc.tool_call_id] = bubble - # The next text chunk should start a fresh bubble - # so tool output and prose stay separated. - text_bubble = None + app.show_tool_call(tc.tool_name, tc.tool_args) elif isinstance(event, ai.events.ToolCallResult): for part in event.results: - preview = _format_tool_result(part.result, part.is_error) - app.transcript.add_bubble("tool", preview) + app.show_tool_result(part.result, part.is_error) elif isinstance(event, ai.events.HookEvent): app.on_hook_event(event.hook) - if following: - app.transcript.scroll_end(animate=False) + app.follow_scroll() except asyncio.CancelledError: interrupted = True # Persist whatever the agent added (assistant + tool turns) @@ -669,9 +648,8 @@ def on_mount(self) -> None: def _start_new_session(self) -> None: self.session.start(MODEL_ID) - self.transcript.add_bubble( - "system", - f"connected — model: {MODEL_ID} session: {self.session.session_id}", + self.show_system( + f"connected — model: {MODEL_ID} session: {self.session.session_id}" ) def _restore_session(self, path: pathlib.Path) -> None: @@ -691,8 +669,7 @@ def _restore_session(self, path: pathlib.Path) -> None: part.result, getattr(part, "is_error", False) ) self.transcript.add_bubble("tool", preview) - self.transcript.add_bubble( - "system", + self.show_system( f"resumed session {self.session.session_id} " f"({len(self.session.messages) - 1} messages) — model: {MODEL_ID}", ) @@ -721,6 +698,52 @@ def _update_usage_display(self) -> None: def transcript(self) -> Transcript: return self.query_one("#transcript", Transcript) + # ------------------------------------------------------------------ + # Rendering — called by the agent loop + # ------------------------------------------------------------------ + + # Per-turn bubble state. Reset at the start of each turn via + # ``run_turn``; the agent loop calls the methods below which + # lazily create bubbles as needed. + _text_bubble: Bubble | None = None + _thinking_bubble: Bubble | None = None + + def _reset_turn_bubbles(self) -> None: + self._text_bubble = None + self._thinking_bubble = None + + def append_thinking(self, chunk: str) -> None: + """Append a reasoning/thinking chunk (lazily creates the bubble).""" + if self._thinking_bubble is None: + self._thinking_bubble = self.transcript.add_bubble("thinking") + self._thinking_bubble.append(chunk) + + def append_text(self, chunk: str) -> None: + """Append an assistant text chunk (lazily creates the bubble).""" + if self._text_bubble is None: + self._text_bubble = self.transcript.add_bubble("assistant") + self._text_bubble.append(chunk) + + def show_tool_call(self, name: str, args: str) -> None: + """Show a completed tool invocation line.""" + self.transcript.add_bubble("tool", _format_tool_call(name, args)) + # Next text from the model should start a fresh bubble so + # tool output and prose stay visually separated. + self._text_bubble = None + + def show_tool_result(self, result: Any, is_error: bool) -> None: + """Show the (possibly truncated) result of a tool call.""" + self.transcript.add_bubble("tool", _format_tool_result(result, is_error)) + + def show_system(self, text: str) -> None: + """Show a system/status message.""" + self.transcript.add_bubble("system", text) + + def follow_scroll(self) -> None: + """Scroll to the bottom if the user was already there.""" + if self.transcript.at_bottom: + self.transcript.scroll_end(animate=False) + # ------------------------------------------------------------------ # Input → turn # ------------------------------------------------------------------ @@ -751,6 +774,7 @@ async def on_composer_submitted(self, event: Composer.Submitted) -> None: @textual.work(exclusive=True, group="turn") async def run_turn(self) -> None: self._turn_worker = textual.worker.get_current_worker() + self._reset_turn_bubbles() try: await chat_loop(self) finally: @@ -814,9 +838,8 @@ def _resolve_hook(self, hook: ai.messages.HookPart[Any], *, granted: bool) -> No reason="operator approved" if granted else "operator denied", ), ) - self.transcript.add_bubble( - "system", - f"{'approved' if granted else 'denied'}: {hook.metadata.get('tool', '?')}", + self.show_system( + f"{'approved' if granted else 'denied'}: {hook.metadata.get('tool', '?')}" ) async def on_hook_prompt_decided(self, event: HookPrompt.Decided) -> None: From a76795cccc4f3b39caabf28c669fde97a7c48c51 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Tue, 19 May 2026 17:09:56 -0700 Subject: [PATCH 34/73] [tau] Use show_tool_result in session replay Co-authored-by: anthropic/claude-opus-4.6, via tau --- examples/tau-agent/tau/app.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/examples/tau-agent/tau/app.py b/examples/tau-agent/tau/app.py index 5896d436..b71c8d2a 100644 --- a/examples/tau-agent/tau/app.py +++ b/examples/tau-agent/tau/app.py @@ -665,10 +665,9 @@ def _restore_session(self, path: pathlib.Path) -> None: elif msg.role == "tool": for part in msg.parts: if hasattr(part, "result"): - preview = _format_tool_result( + self.show_tool_result( part.result, getattr(part, "is_error", False) ) - self.transcript.add_bubble("tool", preview) self.show_system( f"resumed session {self.session.session_id} " f"({len(self.session.messages) - 1} messages) — model: {MODEL_ID}", From ff6e6c9e7aaf1ef6987df1754646b20f50703494 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Tue, 19 May 2026 17:11:06 -0700 Subject: [PATCH 35/73] [tau] Extract _replay_session from TauApp._restore_session Session replay (iterating messages and creating bubbles) is now a free function alongside chat_loop, following the same pattern of taking the app and calling its rendering methods. Co-authored-by: anthropic/claude-opus-4.6, via tau --- examples/tau-agent/tau/app.py | 40 +++++++++++++++++++---------------- 1 file changed, 22 insertions(+), 18 deletions(-) diff --git a/examples/tau-agent/tau/app.py b/examples/tau-agent/tau/app.py index b71c8d2a..7488eeac 100644 --- a/examples/tau-agent/tau/app.py +++ b/examples/tau-agent/tau/app.py @@ -151,6 +151,27 @@ def refresh_usage(self) -> None: # --------------------------------------------------------------------------- +def _replay_session(app: TauApp) -> None: + """Replay persisted messages into the transcript after a restore.""" + for msg in app.session.messages: + if msg.role == "system": + continue + if msg.role == "user": + app.transcript.add_bubble("user", msg.text) + elif msg.role == "assistant": + app.transcript.add_bubble("assistant", msg.text) + elif msg.role == "tool": + for part in msg.parts: + if hasattr(part, "result"): + app.show_tool_result( + part.result, getattr(part, "is_error", False) + ) + app.show_system( + f"resumed session {app.session.session_id} " + f"({len(app.session.messages) - 1} messages) — model: {MODEL_ID}", + ) + + async def chat_loop(app: TauApp) -> None: """Drain the pending queue, running one agent turn per queued message. @@ -654,24 +675,7 @@ def _start_new_session(self) -> None: def _restore_session(self, path: pathlib.Path) -> None: self.session.restore(path) - # Replay conversation into transcript bubbles. - for msg in self.session.messages: - if msg.role == "system": - continue # don't clutter the UI with the system prompt - if msg.role == "user": - self.transcript.add_bubble("user", msg.text) - elif msg.role == "assistant": - self.transcript.add_bubble("assistant", msg.text) - elif msg.role == "tool": - for part in msg.parts: - if hasattr(part, "result"): - self.show_tool_result( - part.result, getattr(part, "is_error", False) - ) - self.show_system( - f"resumed session {self.session.session_id} " - f"({len(self.session.messages) - 1} messages) — model: {MODEL_ID}", - ) + _replay_session(self) self.session.refresh_usage() self._update_usage_display() From e54139131590149317479ea2552ea282ec786ecc Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Tue, 19 May 2026 17:12:01 -0700 Subject: [PATCH 36/73] [tau] Use isinstance instead of hasattr for ToolResultPart check Co-authored-by: anthropic/claude-opus-4.6, via tau --- examples/tau-agent/tau/app.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/examples/tau-agent/tau/app.py b/examples/tau-agent/tau/app.py index 7488eeac..46a6b2e0 100644 --- a/examples/tau-agent/tau/app.py +++ b/examples/tau-agent/tau/app.py @@ -162,10 +162,8 @@ def _replay_session(app: TauApp) -> None: app.transcript.add_bubble("assistant", msg.text) elif msg.role == "tool": for part in msg.parts: - if hasattr(part, "result"): - app.show_tool_result( - part.result, getattr(part, "is_error", False) - ) + if isinstance(part, ai.messages.ToolResultPart): + app.show_tool_result(part.result, part.is_error) app.show_system( f"resumed session {app.session.session_id} " f"({len(app.session.messages) - 1} messages) — model: {MODEL_ID}", From ac4a87cfd759078571ba369d58c9fbb08ede3f16 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Tue, 19 May 2026 17:29:11 -0700 Subject: [PATCH 37/73] [tau] Consolidate approval decision handling into ApprovalTracker.remember Replace the three separate methods (approve_command, approve_directory, approve_all) and the if-chain in on_hook_prompt_decided with a single remember(hook, decision) method on ApprovalTracker. Co-authored-by: anthropic/claude-opus-4.6, via tau --- examples/tau-agent/tau/app.py | 53 ++++++++++++++++------------------- 1 file changed, 24 insertions(+), 29 deletions(-) diff --git a/examples/tau-agent/tau/app.py b/examples/tau-agent/tau/app.py index 46a6b2e0..9b3e9c43 100644 --- a/examples/tau-agent/tau/app.py +++ b/examples/tau-agent/tau/app.py @@ -339,28 +339,29 @@ def check(self, hook: ai.messages.HookPart[Any]) -> bool | None: return True return None - def approve_command(self, hook: ai.messages.HookPart[Any]) -> None: - """Remember to always approve this exact bash command.""" - kwargs = hook.metadata.get("kwargs", {}) or {} - cmd = kwargs.get("command", "") - if cmd: - self._approved_commands.add(cmd) - - def approve_directory(self, hook: ai.messages.HookPart[Any]) -> None: - """Allow all future operations in this path's directory.""" - tool = hook.metadata.get("tool", "") - path = _tool_path(hook) - if path is None: - return - directory = path if path.is_dir() else path.parent - if tool in _READ_TOOLS: - self._approved_read_dirs.add(directory) - elif tool in _WRITE_TOOLS: - self._approved_write_dirs.add(directory) + def remember(self, hook: ai.messages.HookPart[Any], decision: str) -> None: + """Update approval state based on an operator decision. - def approve_all(self) -> None: - """Auto-approve every future tool call this session.""" - self._approve_all = True + Only ``'always_this'``, ``'allow_dir'``, and ``'always_all'`` + have lasting effects; ``'yes'`` and ``'no'`` are one-shot. + """ + if decision == "always_this": + kwargs = hook.metadata.get("kwargs", {}) or {} + cmd = kwargs.get("command", "") + if cmd: + self._approved_commands.add(cmd) + elif decision == "allow_dir": + tool = hook.metadata.get("tool", "") + path = _tool_path(hook) + if path is None: + return + directory = path if path.is_dir() else path.parent + if tool in _READ_TOOLS: + self._approved_read_dirs.add(directory) + elif tool in _WRITE_TOOLS: + self._approved_write_dirs.add(directory) + elif decision == "always_all": + self._approve_all = True # --------------------------------------------------------------------------- @@ -847,14 +848,8 @@ async def on_hook_prompt_decided(self, event: HookPrompt.Decided) -> None: hook = self._active_hook if hook is None or hook.hook_id != event.hook_id: return - granted = event.decision != "no" - if event.decision == "always_this": - self._approval.approve_command(hook) - elif event.decision == "allow_dir": - self._approval.approve_directory(hook) - elif event.decision == "always_all": - self._approval.approve_all() - self._resolve_hook(hook, granted=granted) + self._approval.remember(hook, event.decision) + self._resolve_hook(hook, granted=event.decision != "no") self._dismiss_active_prompt() self._activate_next_hook() From e5656154df8d95da810ab88a21a438c7ed4592be Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Tue, 19 May 2026 17:31:26 -0700 Subject: [PATCH 38/73] [tau] Move hook resolution into ApprovalTracker.resolve MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ApprovalTracker.resolve handles both updating approval state and calling ai.resolve_hook. Removes _resolve_hook from TauApp — the app just shows the system message based on the returned bool. Co-authored-by: anthropic/claude-opus-4.6, via tau --- examples/tau-agent/tau/app.py | 59 ++++++++++++++++++----------------- 1 file changed, 30 insertions(+), 29 deletions(-) diff --git a/examples/tau-agent/tau/app.py b/examples/tau-agent/tau/app.py index 9b3e9c43..451b5a3d 100644 --- a/examples/tau-agent/tau/app.py +++ b/examples/tau-agent/tau/app.py @@ -339,12 +339,14 @@ def check(self, hook: ai.messages.HookPart[Any]) -> bool | None: return True return None - def remember(self, hook: ai.messages.HookPart[Any], decision: str) -> None: - """Update approval state based on an operator decision. + def resolve(self, hook: ai.messages.HookPart[Any], decision: str) -> bool: + """Resolve a hook: update approval state, signal the library. - Only ``'always_this'``, ``'allow_dir'``, and ``'always_all'`` - have lasting effects; ``'yes'`` and ``'no'`` are one-shot. + *decision* is one of ``'yes'``, ``'no'``, ``'always_this'``, + ``'allow_dir'``, ``'always_all'``. Returns whether the hook + was granted. """ + # Remember lasting decisions. if decision == "always_this": kwargs = hook.metadata.get("kwargs", {}) or {} cmd = kwargs.get("command", "") @@ -353,16 +355,25 @@ def remember(self, hook: ai.messages.HookPart[Any], decision: str) -> None: elif decision == "allow_dir": tool = hook.metadata.get("tool", "") path = _tool_path(hook) - if path is None: - return - directory = path if path.is_dir() else path.parent - if tool in _READ_TOOLS: - self._approved_read_dirs.add(directory) - elif tool in _WRITE_TOOLS: - self._approved_write_dirs.add(directory) + if path is not None: + directory = path if path.is_dir() else path.parent + if tool in _READ_TOOLS: + self._approved_read_dirs.add(directory) + elif tool in _WRITE_TOOLS: + self._approved_write_dirs.add(directory) elif decision == "always_all": self._approve_all = True + granted = decision != "no" + ai.resolve_hook( + hook.hook_id, + ai.tools.ToolApproval( + granted=granted, + reason="operator approved" if granted else "operator denied", + ), + ) + return granted + # --------------------------------------------------------------------------- # Widgets @@ -798,9 +809,11 @@ def action_interrupt(self) -> None: def on_hook_event(self, hook: ai.messages.HookPart[Any]) -> None: if hook.status == "pending": # Check if the tracker can auto-resolve this hook. - decision = self._approval.check(hook) - if decision is not None: - self._resolve_hook(hook, granted=decision) + auto = self._approval.check(hook) + if auto is not None: + self._approval.resolve(hook, "yes" if auto else "no") + tool = hook.metadata.get("tool", "?") + self.show_system(f"{'approved' if auto else 'denied'}: {tool}") return self._hook_queue.append(hook) self._activate_next_hook() @@ -831,25 +844,13 @@ def _dismiss_active_prompt(self) -> None: self._active_hook = None self.query_one("#composer", Composer).focus() - def _resolve_hook(self, hook: ai.messages.HookPart[Any], *, granted: bool) -> None: - """Resolve a hook and show a transcript note.""" - ai.resolve_hook( - hook.hook_id, - ai.tools.ToolApproval( - granted=granted, - reason="operator approved" if granted else "operator denied", - ), - ) - self.show_system( - f"{'approved' if granted else 'denied'}: {hook.metadata.get('tool', '?')}" - ) - async def on_hook_prompt_decided(self, event: HookPrompt.Decided) -> None: hook = self._active_hook if hook is None or hook.hook_id != event.hook_id: return - self._approval.remember(hook, event.decision) - self._resolve_hook(hook, granted=event.decision != "no") + granted = self._approval.resolve(hook, event.decision) + tool = hook.metadata.get("tool", "?") + self.show_system(f"{'approved' if granted else 'denied'}: {tool}") self._dismiss_active_prompt() self._activate_next_hook() From 179e81604671c98d6986c1d301daa8a307d0cbaf Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Tue, 19 May 2026 17:44:27 -0700 Subject: [PATCH 39/73] [tau] Introduce Hook dataclass to decouple UI from ai.messages.HookPart All UI code (HookPrompt, ApprovalTracker, TauApp hook plumbing) now works with our own Hook type. The ai.messages.HookPart is converted once in _run_turn via Hook.from_event. The only remaining reference to HookPart is in that constructor. Co-authored-by: anthropic/claude-opus-4.6, via tau --- examples/tau-agent/tau/app.py | 76 +++++++++++++++++++++-------------- 1 file changed, 46 insertions(+), 30 deletions(-) diff --git a/examples/tau-agent/tau/app.py b/examples/tau-agent/tau/app.py index 451b5a3d..a97dc96e 100644 --- a/examples/tau-agent/tau/app.py +++ b/examples/tau-agent/tau/app.py @@ -17,6 +17,7 @@ import argparse import asyncio +import dataclasses import json import os import pathlib @@ -208,7 +209,7 @@ async def _run_turn(app: TauApp) -> None: for part in event.results: app.show_tool_result(part.result, part.is_error) elif isinstance(event, ai.events.HookEvent): - app.on_hook_event(event.hook) + app.on_hook_event(Hook.from_event(event.hook)) app.follow_scroll() except asyncio.CancelledError: interrupted = True @@ -268,6 +269,30 @@ def _format_tool_result(result: Any, is_error: bool) -> str: return f"\n {marker} {indented}" +# --------------------------------------------------------------------------- +# Hook — thin wrapper so UI code never touches ai.messages.HookPart +# --------------------------------------------------------------------------- + + +@dataclasses.dataclass +class Hook: + """UI-facing snapshot of a tool-approval hook.""" + + hook_id: str + tool: str + kwargs: dict[str, Any] + status: str + + @classmethod + def from_event(cls, part: ai.messages.HookPart[Any]) -> Hook: + return cls( + hook_id=part.hook_id, + tool=part.metadata.get("tool", "?"), + kwargs=part.metadata.get("kwargs", {}) or {}, + status=part.status, + ) + + # --------------------------------------------------------------------------- # Approval tracking # --------------------------------------------------------------------------- @@ -278,10 +303,9 @@ def _format_tool_result(result: Any, is_error: bool) -> str: _FILE_TOOLS = _READ_TOOLS | _WRITE_TOOLS -def _tool_path(hook: ai.messages.HookPart[Any]) -> pathlib.Path | None: +def _tool_path(hook: Hook) -> pathlib.Path | None: """Extract and resolve the path argument from a file-tool hook.""" - kwargs = hook.metadata.get("kwargs", {}) or {} - raw = kwargs.get("path") + raw = hook.kwargs.get("path") if raw is None: return None return pathlib.Path(raw).expanduser().resolve() @@ -325,21 +349,19 @@ def _path_ok(self, tool: str, path: pathlib.Path | None) -> bool: ) return any(path == d or d in path.parents for d in dirs) - def check(self, hook: ai.messages.HookPart[Any]) -> bool | None: + def check(self, hook: Hook) -> bool | None: """Return True to auto-approve, False to auto-deny, None to prompt.""" if self._approve_all: return True - tool = hook.metadata.get("tool", "") - kwargs = hook.metadata.get("kwargs", {}) or {} - if tool in _FILE_TOOLS: - return True if self._path_ok(tool, _tool_path(hook)) else None - if tool == "bash": - cmd = kwargs.get("command", "") + if hook.tool in _FILE_TOOLS: + return True if self._path_ok(hook.tool, _tool_path(hook)) else None + if hook.tool == "bash": + cmd = hook.kwargs.get("command", "") if cmd in self._approved_commands: return True return None - def resolve(self, hook: ai.messages.HookPart[Any], decision: str) -> bool: + def resolve(self, hook: Hook, decision: str) -> bool: """Resolve a hook: update approval state, signal the library. *decision* is one of ``'yes'``, ``'no'``, ``'always_this'``, @@ -348,18 +370,16 @@ def resolve(self, hook: ai.messages.HookPart[Any], decision: str) -> bool: """ # Remember lasting decisions. if decision == "always_this": - kwargs = hook.metadata.get("kwargs", {}) or {} - cmd = kwargs.get("command", "") + cmd = hook.kwargs.get("command", "") if cmd: self._approved_commands.add(cmd) elif decision == "allow_dir": - tool = hook.metadata.get("tool", "") path = _tool_path(hook) if path is not None: directory = path if path.is_dir() else path.parent - if tool in _READ_TOOLS: + if hook.tool in _READ_TOOLS: self._approved_read_dirs.add(directory) - elif tool in _WRITE_TOOLS: + elif hook.tool in _WRITE_TOOLS: self._approved_write_dirs.add(directory) elif decision == "always_all": self._approve_all = True @@ -555,18 +575,16 @@ def __init__(self, hook_id: str, decision: str) -> None: # 'yes' | 'no' | 'always_this' | 'allow_dir' | 'always_all' self.decision = decision - def __init__(self, hook: ai.messages.HookPart[Any]) -> None: + def __init__(self, hook: Hook) -> None: super().__init__() self._hook_id = hook.hook_id - tool = hook.metadata.get("tool", "?") - kwargs = hook.metadata.get("kwargs", {}) or {} - is_file_tool = tool in _FILE_TOOLS + is_file_tool = hook.tool in _FILE_TOOLS body = rich.text.Text() body.append("approve ", style="bold yellow") - body.append(tool, style="bold") + body.append(hook.tool, style="bold") body.append("?\n") - body.append(" " + _format_kwargs(kwargs), style="dim") + body.append(" " + _format_kwargs(hook.kwargs), style="dim") body.append("\n ") body.append("[y]", style="bold green") body.append(" yes ") @@ -652,8 +670,8 @@ def __init__( # Approval hooks waiting for operator y/n. FIFO queue: only the # head hook is "active" — ``_active_hook`` mirrors it for fast # access from the composer. - self._hook_queue: list[ai.messages.HookPart[Any]] = [] - self._active_hook: ai.messages.HookPart[Any] | None = None + self._hook_queue: list[Hook] = [] + self._active_hook: Hook | None = None self._approval = ApprovalTracker() self._turn_worker: textual.worker.Worker[None] | None = None self._resume_path = resume_path @@ -806,14 +824,13 @@ def action_interrupt(self) -> None: # Hook plumbing # ------------------------------------------------------------------ - def on_hook_event(self, hook: ai.messages.HookPart[Any]) -> None: + def on_hook_event(self, hook: Hook) -> None: if hook.status == "pending": # Check if the tracker can auto-resolve this hook. auto = self._approval.check(hook) if auto is not None: self._approval.resolve(hook, "yes" if auto else "no") - tool = hook.metadata.get("tool", "?") - self.show_system(f"{'approved' if auto else 'denied'}: {tool}") + self.show_system(f"{'approved' if auto else 'denied'}: {hook.tool}") return self._hook_queue.append(hook) self._activate_next_hook() @@ -849,8 +866,7 @@ async def on_hook_prompt_decided(self, event: HookPrompt.Decided) -> None: if hook is None or hook.hook_id != event.hook_id: return granted = self._approval.resolve(hook, event.decision) - tool = hook.metadata.get("tool", "?") - self.show_system(f"{'approved' if granted else 'denied'}: {tool}") + self.show_system(f"{'approved' if granted else 'denied'}: {hook.tool}") self._dismiss_active_prompt() self._activate_next_hook() From 0f565d45b39f55d3eb5e2a029855085c676b4447 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Tue, 19 May 2026 17:51:24 -0700 Subject: [PATCH 40/73] [tau] Extract _resolve_hook helper for resolve + show_system Co-authored-by: anthropic/claude-opus-4.6, via tau --- examples/tau-agent/tau/app.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/examples/tau-agent/tau/app.py b/examples/tau-agent/tau/app.py index a97dc96e..6cdea3b9 100644 --- a/examples/tau-agent/tau/app.py +++ b/examples/tau-agent/tau/app.py @@ -824,13 +824,17 @@ def action_interrupt(self) -> None: # Hook plumbing # ------------------------------------------------------------------ + def _resolve_hook(self, hook: Hook, decision: str) -> None: + """Resolve a hook and show a transcript note.""" + granted = self._approval.resolve(hook, decision) + self.show_system(f"{'approved' if granted else 'denied'}: {hook.tool}") + def on_hook_event(self, hook: Hook) -> None: if hook.status == "pending": # Check if the tracker can auto-resolve this hook. auto = self._approval.check(hook) if auto is not None: - self._approval.resolve(hook, "yes" if auto else "no") - self.show_system(f"{'approved' if auto else 'denied'}: {hook.tool}") + self._resolve_hook(hook, "yes" if auto else "no") return self._hook_queue.append(hook) self._activate_next_hook() @@ -865,8 +869,7 @@ async def on_hook_prompt_decided(self, event: HookPrompt.Decided) -> None: hook = self._active_hook if hook is None or hook.hook_id != event.hook_id: return - granted = self._approval.resolve(hook, event.decision) - self.show_system(f"{'approved' if granted else 'denied'}: {hook.tool}") + self._resolve_hook(hook, event.decision) self._dismiss_active_prompt() self._activate_next_hook() From d05ff7e14077d865be9e3b3ada971a3807e79ad7 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Tue, 19 May 2026 17:59:46 -0700 Subject: [PATCH 41/73] [tau] Move prompt option logic from HookPrompt into ApprovalTracker ApprovalTracker.check now returns a list of PromptOption when the operator needs to decide, instead of bare None. HookPrompt renders whatever options it's given and uses _on_key for dynamic dispatch instead of static BINDINGS. The widget no longer knows about tool categories. Co-authored-by: anthropic/claude-opus-4.6, via tau --- examples/tau-agent/tau/app.py | 96 ++++++++++++++++++++--------------- 1 file changed, 56 insertions(+), 40 deletions(-) diff --git a/examples/tau-agent/tau/app.py b/examples/tau-agent/tau/app.py index 6cdea3b9..51b95b00 100644 --- a/examples/tau-agent/tau/app.py +++ b/examples/tau-agent/tau/app.py @@ -293,6 +293,15 @@ def from_event(cls, part: ai.messages.HookPart[Any]) -> Hook: ) +@dataclasses.dataclass +class PromptOption: + """One option in an approval prompt.""" + + key: str # keyboard shortcut + decision: str # value passed to ApprovalTracker.resolve + label: str # display text + + # --------------------------------------------------------------------------- # Approval tracking # --------------------------------------------------------------------------- @@ -349,17 +358,33 @@ def _path_ok(self, tool: str, path: pathlib.Path | None) -> bool: ) return any(path == d or d in path.parents for d in dirs) - def check(self, hook: Hook) -> bool | None: - """Return True to auto-approve, False to auto-deny, None to prompt.""" + def check(self, hook: Hook) -> bool | list[PromptOption]: + """Auto-resolve or return prompt options. + + Returns ``True``/``False`` to auto-approve/deny, or a list of + :class:`PromptOption` when the operator needs to decide. + """ if self._approve_all: return True if hook.tool in _FILE_TOOLS: - return True if self._path_ok(hook.tool, _tool_path(hook)) else None + if self._path_ok(hook.tool, _tool_path(hook)): + return True + return [ + PromptOption("y", "yes", "yes"), + PromptOption("n", "no", "no"), + PromptOption("d", "allow_dir", "allow dir"), + PromptOption("a", "always_all", "always all"), + ] if hook.tool == "bash": cmd = hook.kwargs.get("command", "") if cmd in self._approved_commands: return True - return None + return [ + PromptOption("y", "yes", "yes"), + PromptOption("n", "no", "no"), + PromptOption("!", "always_this", "always this"), + PromptOption("a", "always_all", "always all"), + ] def resolve(self, hook: Hook, decision: str) -> bool: """Resolve a hook: update approval state, signal the library. @@ -556,29 +581,18 @@ class HookPrompt(textual.widgets.Static): } """ - BINDINGS = [ - textual.binding.Binding("y", "decide('yes')", "approve", show=True), - textual.binding.Binding("n", "decide('no')", "deny", show=True), - textual.binding.Binding( - "exclamation_mark", "decide('always_this')", "always this", show=True - ), - textual.binding.Binding("d", "decide('allow_dir')", "allow dir", show=True), - textual.binding.Binding("a", "decide('always_all')", "always all", show=True), - ] - can_focus = True class Decided(textual.message.Message): def __init__(self, hook_id: str, decision: str) -> None: super().__init__() self.hook_id = hook_id - # 'yes' | 'no' | 'always_this' | 'allow_dir' | 'always_all' self.decision = decision - def __init__(self, hook: Hook) -> None: + def __init__(self, hook: Hook, options: list[PromptOption]) -> None: super().__init__() self._hook_id = hook.hook_id - is_file_tool = hook.tool in _FILE_TOOLS + self._options = {opt.key: opt.decision for opt in options} body = rich.text.Text() body.append("approve ", style="bold yellow") @@ -586,22 +600,24 @@ def __init__(self, hook: Hook) -> None: body.append("?\n") body.append(" " + _format_kwargs(hook.kwargs), style="dim") body.append("\n ") - body.append("[y]", style="bold green") - body.append(" yes ") - body.append("[n]", style="bold red") - body.append(" no ") - if is_file_tool: - body.append("[d]", style="bold cyan") - body.append(" allow dir ") - else: - body.append("[!]", style="bold cyan") - body.append(" always this ") - body.append("[a]", style="bold cyan") - body.append(" always all") + for i, opt in enumerate(options): + style = ( + "bold green" if opt.decision == "yes" + else "bold red" if opt.decision == "no" + else "bold cyan" + ) + if i > 0: + body.append(" ") + body.append(f"[{opt.key}]", style=style) + body.append(f" {opt.label}") self.update(body) - def action_decide(self, decision: str) -> None: - self.post_message(self.Decided(self._hook_id, decision)) + async def _on_key(self, event: textual.events.Key) -> None: + decision = self._options.get(event.character or "") + if decision is not None: + event.stop() + event.prevent_default() + self.post_message(self.Decided(self._hook_id, decision)) def _format_kwargs(kwargs: dict[str, Any]) -> str: @@ -670,7 +686,7 @@ def __init__( # Approval hooks waiting for operator y/n. FIFO queue: only the # head hook is "active" — ``_active_hook`` mirrors it for fast # access from the composer. - self._hook_queue: list[Hook] = [] + self._hook_queue: list[tuple[Hook, list[PromptOption]]] = [] self._active_hook: Hook | None = None self._approval = ApprovalTracker() self._turn_worker: textual.worker.Worker[None] | None = None @@ -831,17 +847,17 @@ def _resolve_hook(self, hook: Hook, decision: str) -> None: def on_hook_event(self, hook: Hook) -> None: if hook.status == "pending": - # Check if the tracker can auto-resolve this hook. - auto = self._approval.check(hook) - if auto is not None: - self._resolve_hook(hook, "yes" if auto else "no") + result = self._approval.check(hook) + if isinstance(result, bool): + self._resolve_hook(hook, "yes" if result else "no") return - self._hook_queue.append(hook) + self._hook_queue.append((hook, result)) self._activate_next_hook() elif hook.status in ("resolved", "cancelled"): # Drop from queue if it was sitting there waiting. self._hook_queue = [ - h for h in self._hook_queue if h.hook_id != hook.hook_id + (h, opts) for h, opts in self._hook_queue + if h.hook_id != hook.hook_id ] if self._active_hook and self._active_hook.hook_id == hook.hook_id: self._dismiss_active_prompt() @@ -850,9 +866,9 @@ def on_hook_event(self, hook: Hook) -> None: def _activate_next_hook(self) -> None: if self._active_hook is not None or not self._hook_queue: return - hook = self._hook_queue.pop(0) + hook, options = self._hook_queue.pop(0) self._active_hook = hook - prompt = HookPrompt(hook) + prompt = HookPrompt(hook, options) dock = self.query_one("#composer-dock", textual.containers.Container) composer = self.query_one("#composer", Composer) dock.mount(prompt, before=composer) From 5630274a08c012c1d49254db4fda5c02495b4176 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Tue, 19 May 2026 18:02:24 -0700 Subject: [PATCH 42/73] [tau] Fix _format_tool_result leading blank line Put the arrow marker before the newline, not after it, so tool result bubbles don't start with a blank line. Co-authored-by: anthropic/claude-opus-4.6, via tau --- examples/tau-agent/tau/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/tau-agent/tau/app.py b/examples/tau-agent/tau/app.py index 51b95b00..9c3598f2 100644 --- a/examples/tau-agent/tau/app.py +++ b/examples/tau-agent/tau/app.py @@ -266,7 +266,7 @@ def _format_tool_result(result: Any, is_error: bool) -> str: ) marker = "✗" if is_error else "←" indented = "\n ".join(text.splitlines() or [""]) - return f"\n {marker} {indented}" + return f"{marker}\n {indented}" # --------------------------------------------------------------------------- From 536c38e5204d2e3f0375d89497178425233341e8 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Tue, 19 May 2026 18:05:55 -0700 Subject: [PATCH 43/73] [tau] Minor cleanups: _bell, on_text_area_changed, require_approval comment - Move _bell from TauApp staticmethod to module-level function. - Drop unnecessary async from on_text_area_changed. - Update tools.py docstring to explain why all tools use require_approval=True. Co-authored-by: anthropic/claude-opus-4.6, via tau --- examples/tau-agent/tau/app.py | 26 +++++++++++++------------- examples/tau-agent/tau/tools.py | 11 +++++++---- 2 files changed, 20 insertions(+), 17 deletions(-) diff --git a/examples/tau-agent/tau/app.py b/examples/tau-agent/tau/app.py index 9c3598f2..ae1c5fec 100644 --- a/examples/tau-agent/tau/app.py +++ b/examples/tau-agent/tau/app.py @@ -420,6 +420,16 @@ def resolve(self, hook: Hook, decision: str) -> bool: return granted +def _bell() -> None: + """Ring the terminal bell to notify the operator.""" + try: + with open("/dev/tty", "w") as tty: + tty.write("\a") + tty.flush() + except OSError: + pass + + # --------------------------------------------------------------------------- # Widgets # --------------------------------------------------------------------------- @@ -795,7 +805,7 @@ def follow_scroll(self) -> None: # Input → turn # ------------------------------------------------------------------ - async def on_text_area_changed( + def on_text_area_changed( self, event: textual.widgets.TextArea.Changed ) -> None: # Grow/shrink the composer as the user types or wraps. @@ -873,7 +883,7 @@ def _activate_next_hook(self) -> None: composer = self.query_one("#composer", Composer) dock.mount(prompt, before=composer) prompt.focus() - self._bell() + _bell() def _dismiss_active_prompt(self) -> None: for prompt in self.query(HookPrompt).results(): @@ -889,20 +899,10 @@ async def on_hook_prompt_decided(self, event: HookPrompt.Decided) -> None: self._dismiss_active_prompt() self._activate_next_hook() - @staticmethod - def _bell() -> None: - """Ring the terminal bell to notify the operator.""" - try: - with open("/dev/tty", "w") as tty: - tty.write("\a") - tty.flush() - except OSError: - pass - def _set_busy(self, busy: bool) -> None: self._busy = busy if not busy: - self._bell() + _bell() # Composer stays enabled while busy — the user can keep typing # and queue the next message. Only the placeholder changes. inp = self.query_one("#composer", Composer) diff --git a/examples/tau-agent/tau/tools.py b/examples/tau-agent/tau/tools.py index 07e4433b..8928d781 100644 --- a/examples/tau-agent/tau/tools.py +++ b/examples/tau-agent/tau/tools.py @@ -1,10 +1,13 @@ """tau's coding tools — pi's seven built-ins, plain Python. Mirrors pi's tool surface (read, write, edit, bash, grep, find, ls) so -the model gets the same affordances. Mutating tools (write, edit, -bash) are flagged ``require_approval=True``; the agent's default loop -gates them behind a ``ToolApproval`` hook that tau renders as a y/n -prompt in the composer. +the model gets the same affordances. + +All tools use ``require_approval=True`` so the ``ai`` library fires a +hook for every invocation. The ``ApprovalTracker`` in ``tau.app`` +auto-approves safe cases (e.g. reads under cwd) without prompting the +operator; this is the only way to get hooks — the library doesn't +support per-invocation gating otherwise. No workspace jail — paths resolve against the process cwd and the host (or the approval flow) is what keeps things in line. From 3baa88bc9ba5704e021563b884d6b4a4df3c0488 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Tue, 19 May 2026 18:08:42 -0700 Subject: [PATCH 44/73] [tau] Stream tool call outputs via PartialToolCallResult Handle ai.events.PartialToolCallResult in the agent loop to stream tool output (e.g. bash) into the transcript as it arrives, instead of waiting for the final ToolCallResult. Each tool_call_id gets its own bubble; final results are skipped for tools that were already streamed. Co-authored-by: anthropic/claude-opus-4.6, via tau --- examples/tau-agent/tau/app.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/examples/tau-agent/tau/app.py b/examples/tau-agent/tau/app.py index ae1c5fec..51b0c2b2 100644 --- a/examples/tau-agent/tau/app.py +++ b/examples/tau-agent/tau/app.py @@ -205,9 +205,15 @@ async def _run_turn(app: TauApp) -> None: elif isinstance(event, ai.events.ToolEnd): tc = event.tool_call app.show_tool_call(tc.tool_name, tc.tool_args) + elif isinstance(event, ai.events.PartialToolCallResult): + app.append_tool_result( + event.tool_call_id, str(event.value) + ) elif isinstance(event, ai.events.ToolCallResult): for part in event.results: - app.show_tool_result(part.result, part.is_error) + # Skip if we already streamed this result. + if part.tool_call_id not in app._tool_result_bubbles: + app.show_tool_result(part.result, part.is_error) elif isinstance(event, ai.events.HookEvent): app.on_hook_event(Hook.from_event(event.hook)) app.follow_scroll() @@ -764,10 +770,12 @@ def transcript(self) -> Transcript: # lazily create bubbles as needed. _text_bubble: Bubble | None = None _thinking_bubble: Bubble | None = None + _tool_result_bubbles: dict[str, Bubble] def _reset_turn_bubbles(self) -> None: self._text_bubble = None self._thinking_bubble = None + self._tool_result_bubbles = {} def append_thinking(self, chunk: str) -> None: """Append a reasoning/thinking chunk (lazily creates the bubble).""" @@ -788,6 +796,14 @@ def show_tool_call(self, name: str, args: str) -> None: # tool output and prose stay visually separated. self._text_bubble = None + def append_tool_result(self, tool_call_id: str, chunk: str) -> None: + """Append a streaming chunk to a tool-result bubble.""" + bubble = self._tool_result_bubbles.get(tool_call_id) + if bubble is None: + bubble = self.transcript.add_bubble("tool") + self._tool_result_bubbles[tool_call_id] = bubble + bubble.append(chunk) + def show_tool_result(self, result: Any, is_error: bool) -> None: """Show the (possibly truncated) result of a tool call.""" self.transcript.add_bubble("tool", _format_tool_result(result, is_error)) From feceda4c71df5ecb5be715606c0976882254508d Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Wed, 20 May 2026 10:47:20 -0700 Subject: [PATCH 45/73] [tau] Set stdin=DEVNULL for bash subprocess Prevents child processes from inheriting the terminal's stdin, which would conflict with Textual's input handling. Co-authored-by: anthropic/claude-opus-4.6, via tau --- examples/tau-agent/tau/tools.py | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/tau-agent/tau/tools.py b/examples/tau-agent/tau/tools.py index 8928d781..e518cb1d 100644 --- a/examples/tau-agent/tau/tools.py +++ b/examples/tau-agent/tau/tools.py @@ -375,6 +375,7 @@ async def bash(command: str, timeout: float | None = None) -> ai.StreamingTextTo """ proc = await asyncio.create_subprocess_shell( command, + stdin=asyncio.subprocess.DEVNULL, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT, ) From d7f88bb96a8323aa86c4e4e2bda313aefe7574b3 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Wed, 20 May 2026 10:52:03 -0700 Subject: [PATCH 46/73] [tau] Replay thinking blocks and tool calls on session restore Iterate assistant message parts instead of just using msg.text, so ReasoningPart (thinking), ToolCallPart (tool invocations), and TextPart are all rendered into the transcript on resume. Co-authored-by: anthropic/claude-opus-4.6, via tau --- examples/tau-agent/tau/app.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/examples/tau-agent/tau/app.py b/examples/tau-agent/tau/app.py index 51b0c2b2..74bfc4f0 100644 --- a/examples/tau-agent/tau/app.py +++ b/examples/tau-agent/tau/app.py @@ -160,7 +160,16 @@ def _replay_session(app: TauApp) -> None: if msg.role == "user": app.transcript.add_bubble("user", msg.text) elif msg.role == "assistant": - app.transcript.add_bubble("assistant", msg.text) + for part in msg.parts: + if isinstance(part, ai.messages.ReasoningPart): + app.transcript.add_bubble("thinking", part.text) + elif isinstance(part, ai.messages.TextPart): + app.transcript.add_bubble("assistant", part.text) + elif isinstance(part, ai.messages.ToolCallPart): + app.transcript.add_bubble( + "tool", + _format_tool_call(part.tool_name, part.tool_args), + ) elif msg.role == "tool": for part in msg.parts: if isinstance(part, ai.messages.ToolResultPart): From 27f320e1cb35f92166461935bc86c7ad3441ed83 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Wed, 20 May 2026 10:57:51 -0700 Subject: [PATCH 47/73] [tau] Add AGENTS.md support Walk up from cwd looking for AGENTS.md and append its contents to the system prompt. Follows the convention used by other coding agents for project-level instructions. Co-authored-by: anthropic/claude-opus-4.6, via tau --- examples/tau-agent/tau/app.py | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/examples/tau-agent/tau/app.py b/examples/tau-agent/tau/app.py index 74bfc4f0..203f286a 100644 --- a/examples/tau-agent/tau/app.py +++ b/examples/tau-agent/tau/app.py @@ -56,7 +56,7 @@ _ADVERTISE = os.environ.get("TAU_ADVERTISE", "") == "1" -SYSTEM_PROMPT = """\ +_BASE_SYSTEM_PROMPT = """\ You are tau, a focused coding assistant running inside a terminal TUI. Keep replies concise and use code blocks when showing code. @@ -72,6 +72,34 @@ else "" ) + +def _find_agents_md() -> str | None: + """Walk up from cwd looking for AGENTS.md; return its contents or None.""" + cur = pathlib.Path.cwd().resolve() + for directory in (cur, *cur.parents): + path = directory / "AGENTS.md" + if path.is_file(): + try: + return path.read_text(encoding="utf-8") + except OSError: + return None + return None + + +def _build_system_prompt() -> str: + prompt = _BASE_SYSTEM_PROMPT + agents_md = _find_agents_md() + if agents_md: + prompt += ( + "\nThe following project-level instructions were loaded from " + "AGENTS.md and should be followed:\n\n" + f"{agents_md}\n" + ) + return prompt + + +SYSTEM_PROMPT = _build_system_prompt() + # How many characters of a tool result to show inline; the full result # still goes to the model. RESULT_PREVIEW_CHARS = 400 From b150157195ffc3dba789406d63b80e2e1eaf0aab Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Wed, 20 May 2026 11:37:05 -0700 Subject: [PATCH 48/73] [tau] Add image support to the read tool Detect image files (jpg, png, gif, webp) via magic bytes and return them as base64-encoded image content parts instead of dumping binary as garbled text. Uses the Vercel AI SDK multi-part content format (text + image) so the gateway can pass images through to vision models. Co-authored-by: anthropic/claude-opus-4.6, via tau --- examples/tau-agent/tau/tools.py | 37 +++++++++++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/examples/tau-agent/tau/tools.py b/examples/tau-agent/tau/tools.py index e518cb1d..d156eb15 100644 --- a/examples/tau-agent/tau/tools.py +++ b/examples/tau-agent/tau/tools.py @@ -16,14 +16,33 @@ from __future__ import annotations import asyncio +import base64 import dataclasses import pathlib import re -from typing import Literal +from typing import Any, Literal import ai import pydantic +# Image formats we support (subset that models typically accept). +_IMAGE_MIME_TYPES = frozenset({ + "image/jpeg", + "image/png", + "image/gif", + "image/webp", +}) + + +def _detect_image_mime(path: pathlib.Path) -> str | None: + """Read magic bytes and return a supported image MIME type, or None.""" + try: + header = path.read_bytes()[:32] + except OSError: + return None + mime = ai.messages.media.detect_image_media_type(header) + return mime if mime in _IMAGE_MIME_TYPES else None + # --------------------------------------------------------------------------- # Truncation — match pi's defaults # --------------------------------------------------------------------------- @@ -200,12 +219,15 @@ async def read( path: str, offset: int | None = None, limit: int | None = None, -) -> str: +) -> Any: """Read the contents of a file. Output is truncated to 2000 lines or 50KB (whichever is hit first). Use offset/limit for large files; when truncated, the result ends with a "Use offset=N to continue" hint. offset is 1-indexed. + + Supports image files (jpg, png, gif, webp) which are returned as + base64-encoded image attachments. """ p = pathlib.Path(path).expanduser() if not p.exists(): @@ -213,6 +235,17 @@ async def read( if not p.is_file(): raise IsADirectoryError(f"Not a file: {path}") + # Image files: return base64 data for the model to see. + mime = _detect_image_mime(p) + if mime is not None: + data = p.read_bytes() + b64 = base64.b64encode(data).decode("ascii") + size_kb = len(data) / 1024 + return [ + {"type": "text", "text": f"Read image file [{mime}, {size_kb:.1f}KB]"}, + {"type": "image", "data": b64, "mimeType": mime}, + ] + text = p.read_text(encoding="utf-8", errors="replace") all_lines = text.split("\n") total_lines = len(all_lines) From 90d819bc6ce7141c32c8acaa80b4e0781431a922 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Wed, 20 May 2026 13:45:21 -0700 Subject: [PATCH 49/73] [tau] Replace manual scroll management with Textual's anchor system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hand-rolled follow_scroll / at_bottom approach had a stale-state bug: after an async gap (e.g. between stream end and error handler), Textual's layout would update and at_bottom would become False even though the user never scrolled. Textual's built-in Widget.anchor() handles all of this correctly: - New content auto-scrolls to the bottom - User scrolls up → anchor releases, no forced scrolling - User scrolls back to bottom → anchor restores Removed: follow_scroll(), at_bottom property, auto_scroll parameter, _following flag, and all manual scroll_end calls. Added: single transcript.anchor() call at mount time. Co-authored-by: anthropic/claude-opus-4.6, via tau --- examples/tau-agent/tau/app.py | 24 +++--------------------- 1 file changed, 3 insertions(+), 21 deletions(-) diff --git a/examples/tau-agent/tau/app.py b/examples/tau-agent/tau/app.py index 203f286a..dc22bba7 100644 --- a/examples/tau-agent/tau/app.py +++ b/examples/tau-agent/tau/app.py @@ -253,7 +253,6 @@ async def _run_turn(app: TauApp) -> None: app.show_tool_result(part.result, part.is_error) elif isinstance(event, ai.events.HookEvent): app.on_hook_event(Hook.from_event(event.hook)) - app.follow_scroll() except asyncio.CancelledError: interrupted = True # Persist whatever the agent added (assistant + tool turns) @@ -539,24 +538,12 @@ class Transcript(textual.containers.VerticalScroll): """ def add_bubble( - self, role: str, text: str = "", *, auto_scroll: bool = False + self, role: str, text: str = "" ) -> Bubble: bubble = Bubble(role, text) self.mount(bubble) - if auto_scroll: - self.scroll_end(animate=False) return bubble - @property - def at_bottom(self) -> bool: - """True when the scrollback is at (or within 1 row of) the end. - - Used to decide whether streaming text should auto-scroll: if - the user has scrolled up to read earlier output, we don't yank - them back down on every chunk. - """ - return self.scroll_y >= self.max_scroll_y - 1 - class Composer(textual.widgets.TextArea): """Multi-line input that grows with its content. @@ -757,7 +744,7 @@ def on_mount(self) -> None: self._restore_session(self._resume_path) else: self._start_new_session() - self.transcript.scroll_end(animate=False) + self.transcript.anchor() self.query_one("#composer", Composer).focus() # ------------------------------------------------------------------ @@ -849,11 +836,6 @@ def show_system(self, text: str) -> None: """Show a system/status message.""" self.transcript.add_bubble("system", text) - def follow_scroll(self) -> None: - """Scroll to the bottom if the user was already there.""" - if self.transcript.at_bottom: - self.transcript.scroll_end(animate=False) - # ------------------------------------------------------------------ # Input → turn # ------------------------------------------------------------------ @@ -870,7 +852,7 @@ async def on_composer_submitted(self, event: Composer.Submitted) -> None: if not text: return - self.transcript.add_bubble("user", text, auto_scroll=True) + self.transcript.add_bubble("user", text) # All submissions enter the queue; ``run_turn`` is the sole # consumer. The user bubble shows up immediately so the message # feels sent even when it won't reach the model until the From 3cfca40e607e7873e5c2d686b4a39483fcfb53c9 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Wed, 20 May 2026 12:22:09 -0700 Subject: [PATCH 50/73] [tau] Support image reads Co-authored-by: anthropic/claude-opus-4.6, via tau --- examples/tau-agent/tau/app.py | 11 ++++++++++- examples/tau-agent/tau/tools.py | 13 ++++++------- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/examples/tau-agent/tau/app.py b/examples/tau-agent/tau/app.py index dc22bba7..a44db6b7 100644 --- a/examples/tau-agent/tau/app.py +++ b/examples/tau-agent/tau/app.py @@ -299,8 +299,17 @@ def _short_value(v: Any) -> str: return s +def _json_default(obj: Any) -> Any: + """json.dumps fallback: serialize pydantic models via model_dump.""" + if hasattr(obj, "model_dump"): + return obj.model_dump(mode="json") + raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable") + + def _format_tool_result(result: Any, is_error: bool) -> str: - text = result if isinstance(result, str) else json.dumps(result, ensure_ascii=False) + text = result if isinstance(result, str) else json.dumps( + result, ensure_ascii=False, default=_json_default + ) if len(text) > RESULT_PREVIEW_CHARS: text = ( text[:RESULT_PREVIEW_CHARS] diff --git a/examples/tau-agent/tau/tools.py b/examples/tau-agent/tau/tools.py index d156eb15..110807bb 100644 --- a/examples/tau-agent/tau/tools.py +++ b/examples/tau-agent/tau/tools.py @@ -16,7 +16,6 @@ from __future__ import annotations import asyncio -import base64 import dataclasses import pathlib import re @@ -227,7 +226,7 @@ async def read( with a "Use offset=N to continue" hint. offset is 1-indexed. Supports image files (jpg, png, gif, webp) which are returned as - base64-encoded image attachments. + base64-encoded data via FilePart objects. """ p = pathlib.Path(path).expanduser() if not p.exists(): @@ -235,15 +234,15 @@ async def read( if not p.is_file(): raise IsADirectoryError(f"Not a file: {path}") - # Image files: return base64 data for the model to see. + # Image files: return a text label + FilePart so providers emit + # a real image content block instead of stringified JSON. mime = _detect_image_mime(p) if mime is not None: data = p.read_bytes() - b64 = base64.b64encode(data).decode("ascii") - size_kb = len(data) / 1024 + size_str = format_size(len(data)) return [ - {"type": "text", "text": f"Read image file [{mime}, {size_kb:.1f}KB]"}, - {"type": "image", "data": b64, "mimeType": mime}, + f"Read image file [{mime}, {size_str}]", + ai.file_part(data, media_type=mime), ] text = p.read_text(encoding="utf-8", errors="replace") From 0a54dd806f5e546d55cf38f8b54cca658ac0e842 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Wed, 20 May 2026 14:22:11 -0700 Subject: [PATCH 51/73] [tau] Use ansi-dark theme for native terminal colors Switch from Textual's default dark theme (which forces #121212 background and #1e1e1e surface) to ansi-dark, which uses the terminal's own background color. Removes explicit background overrides from composer and hook prompt widgets. Co-authored-by: anthropic/claude-opus-4.6, via tau --- examples/tau-agent/tau/app.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/examples/tau-agent/tau/app.py b/examples/tau-agent/tau/app.py index a44db6b7..666bcb37 100644 --- a/examples/tau-agent/tau/app.py +++ b/examples/tau-agent/tau/app.py @@ -621,12 +621,10 @@ class HookPrompt(textual.widgets.Static): height: auto; padding: 0 1; border: round $warning; - background: $surface; margin-bottom: 1; } HookPrompt:focus { border: round $warning; - background: $surface-lighten-1; } """ @@ -695,7 +693,6 @@ class TauApp(textual.app.App[None]): max-height: 12; /* MAX_LINES (10) + 2 for the border */ padding: 0 1; /* breathing room left/right of the cursor */ border: round $surface-lighten-2; - background: $surface; } #usage-bar { height: 1; @@ -710,6 +707,7 @@ class TauApp(textual.app.App[None]): ] TITLE = "tau" + theme = "ansi-dark" # State read by ``chat_loop``. Public on purpose — the agent # function is meant to be readable next to the app. From 14e5f54108aca0cf8e370bef78d157fca70c381a Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Wed, 20 May 2026 17:17:47 -0700 Subject: [PATCH 52/73] [tau] Run formatting, make line length match the toplevel project --- examples/tau-agent/pyproject.toml | 3 +- examples/tau-agent/tau/app.py | 58 ++++++++++++++++++++----------- examples/tau-agent/tau/session.py | 3 +- examples/tau-agent/tau/tools.py | 39 ++++++++++++++------- 4 files changed, 69 insertions(+), 34 deletions(-) diff --git a/examples/tau-agent/pyproject.toml b/examples/tau-agent/pyproject.toml index 333b2419..afa32256 100644 --- a/examples/tau-agent/pyproject.toml +++ b/examples/tau-agent/pyproject.toml @@ -22,7 +22,8 @@ packages = ["tau"] ai = { path = "../..", editable = true } [tool.ruff] -target-version = "py313" +line-length = 80 +target-version = "py312" [tool.ruff.lint] select = ["E", "F", "I", "UP", "B", "SIM"] diff --git a/examples/tau-agent/tau/app.py b/examples/tau-agent/tau/app.py index 666bcb37..db1e3504 100644 --- a/examples/tau-agent/tau/app.py +++ b/examples/tau-agent/tau/app.py @@ -47,7 +47,9 @@ { "providerOptions": { "gateway": {"caching": "auto"}, - "anthropic": {"thinking": {"type": "enabled", "budget_tokens": 10000}}, + "anthropic": { + "thinking": {"type": "enabled", "budget_tokens": 10000} + }, } } if MODEL_ID.startswith("gateway:") @@ -232,7 +234,9 @@ async def chat_loop(app: TauApp) -> None: async def _run_turn(app: TauApp) -> None: """Execute a single agent turn, dispatching events to the app.""" interrupted = False - async with app.agent.run(app.model, app.session.messages, params=STREAM_PARAMS) as stream: + async with app.agent.run( + app.model, app.session.messages, params=STREAM_PARAMS + ) as stream: try: async for event in stream: if isinstance(event, ai.events.ReasoningDelta): @@ -243,9 +247,7 @@ async def _run_turn(app: TauApp) -> None: tc = event.tool_call app.show_tool_call(tc.tool_name, tc.tool_args) elif isinstance(event, ai.events.PartialToolCallResult): - app.append_tool_result( - event.tool_call_id, str(event.value) - ) + app.append_tool_result(event.tool_call_id, str(event.value)) elif isinstance(event, ai.events.ToolCallResult): for part in event.results: # Skip if we already streamed this result. @@ -303,12 +305,16 @@ def _json_default(obj: Any) -> Any: """json.dumps fallback: serialize pydantic models via model_dump.""" if hasattr(obj, "model_dump"): return obj.model_dump(mode="json") - raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable") + raise TypeError( + f"Object of type {type(obj).__name__} is not JSON serializable" + ) def _format_tool_result(result: Any, is_error: bool) -> str: - text = result if isinstance(result, str) else json.dumps( - result, ensure_ascii=False, default=_json_default + text = ( + result + if isinstance(result, str) + else json.dumps(result, ensure_ascii=False, default=_json_default) ) if len(text) > RESULT_PREVIEW_CHARS: text = ( @@ -546,9 +552,7 @@ class Transcript(textual.containers.VerticalScroll): } """ - def add_bubble( - self, role: str, text: str = "" - ) -> Bubble: + def add_bubble(self, role: str, text: str = "") -> Bubble: bubble = Bubble(role, text) self.mount(bubble) return bubble @@ -598,7 +602,9 @@ def refresh_height(self) -> None: # wrapping. Clamp it so the composer never collapses to 0 lines # or eats the whole screen. +2 accounts for the top+bottom of # the rounded border (box-sizing is border-box by default). - n = max(self.MIN_LINES, min(self.MAX_LINES, self.wrapped_document.height)) + n = max( + self.MIN_LINES, min(self.MAX_LINES, self.wrapped_document.height) + ) self.styles.height = n + 2 @@ -649,8 +655,10 @@ def __init__(self, hook: Hook, options: list[PromptOption]) -> None: body.append("\n ") for i, opt in enumerate(options): style = ( - "bold green" if opt.decision == "yes" - else "bold red" if opt.decision == "no" + "bold green" + if opt.decision == "yes" + else "bold red" + if opt.decision == "no" else "bold cyan" ) if i > 0: @@ -689,7 +697,7 @@ class TauApp(textual.app.App[None]): padding: 0 1 1 1; } #composer { - height: 3; /* refresh_height() resizes this dynamically */ + height: 3; /* refresh_height() resizes */ max-height: 12; /* MAX_LINES (10) + 2 for the border */ padding: 0 1; /* breathing room left/right of the cursor */ border: round $surface-lighten-2; @@ -703,7 +711,9 @@ class TauApp(textual.app.App[None]): BINDINGS = [ textual.binding.Binding("ctrl+c", "quit", "quit", priority=True), - textual.binding.Binding("escape", "interrupt", "interrupt", priority=True), + textual.binding.Binding( + "escape", "interrupt", "interrupt", priority=True + ), ] TITLE = "tau" @@ -778,7 +788,10 @@ def _update_usage_display(self) -> None: parts: list[str] = [] # Approximate current context size: last turn's in + out. if self.session.last_usage is not None: - ctx = self.session.last_usage.input_tokens + self.session.last_usage.output_tokens + ctx = ( + self.session.last_usage.input_tokens + + self.session.last_usage.output_tokens + ) parts.append(f"ctx: ~{ctx:,}") # input_tokens includes cache-read; subtract to show uncached. uncached_in = u.input_tokens - (u.cache_read_tokens or 0) @@ -786,7 +799,9 @@ def _update_usage_display(self) -> None: if u.cache_read_tokens: parts.append(f"cached: {u.cache_read_tokens:,}") parts.append(f"out: {u.output_tokens:,}") - self.query_one("#usage-bar", textual.widgets.Static).update(" ".join(parts)) + self.query_one("#usage-bar", textual.widgets.Static).update( + " ".join(parts) + ) @property def transcript(self) -> Transcript: @@ -837,7 +852,9 @@ def append_tool_result(self, tool_call_id: str, chunk: str) -> None: def show_tool_result(self, result: Any, is_error: bool) -> None: """Show the (possibly truncated) result of a tool call.""" - self.transcript.add_bubble("tool", _format_tool_result(result, is_error)) + self.transcript.add_bubble( + "tool", _format_tool_result(result, is_error) + ) def show_system(self, text: str) -> None: """Show a system/status message.""" @@ -908,7 +925,8 @@ def on_hook_event(self, hook: Hook) -> None: elif hook.status in ("resolved", "cancelled"): # Drop from queue if it was sitting there waiting. self._hook_queue = [ - (h, opts) for h, opts in self._hook_queue + (h, opts) + for h, opts in self._hook_queue if h.hook_id != hook.hook_id ] if self._active_hook and self._active_hook.hook_id == hook.hook_id: diff --git a/examples/tau-agent/tau/session.py b/examples/tau-agent/tau/session.py index 70338815..321304c4 100644 --- a/examples/tau-agent/tau/session.py +++ b/examples/tau-agent/tau/session.py @@ -4,7 +4,8 @@ is a JSON-serialised ``ai.messages.Message``. The first line is always a metadata object (not a Message) carrying session-level info: - {"meta": true, "session_id": "...", "model": "...", "cwd": "...", "created": "..."} + {"meta": true, "session_id": "...", "model": "...", + "cwd": "...", "created": "..."} Usage: # New session (default) diff --git a/examples/tau-agent/tau/tools.py b/examples/tau-agent/tau/tools.py index 110807bb..90d9f604 100644 --- a/examples/tau-agent/tau/tools.py +++ b/examples/tau-agent/tau/tools.py @@ -25,12 +25,14 @@ import pydantic # Image formats we support (subset that models typically accept). -_IMAGE_MIME_TYPES = frozenset({ - "image/jpeg", - "image/png", - "image/gif", - "image/webp", -}) +_IMAGE_MIME_TYPES = frozenset( + { + "image/jpeg", + "image/png", + "image/gif", + "image/webp", + } +) def _detect_image_mime(path: pathlib.Path) -> str | None: @@ -42,6 +44,7 @@ def _detect_image_mime(path: pathlib.Path) -> str | None: mime = ai.messages.media.detect_image_media_type(header) return mime if mime in _IMAGE_MIME_TYPES else None + # --------------------------------------------------------------------------- # Truncation — match pi's defaults # --------------------------------------------------------------------------- @@ -372,7 +375,8 @@ async def edit(path: str, edits: list[TextEdit]) -> str: raise ValueError(f"edits[{i}].oldText not found in {path}") if count > 1: raise ValueError( - f"edits[{i}].oldText matches {count} times in {path}; must be unique" + f"edits[{i}].oldText matches {count} times " + f"in {path}; must be unique" ) pos = content.index(e.oldText) spans.append((pos, pos + len(e.oldText), e.newText, i)) @@ -398,7 +402,9 @@ async def edit(path: str, edits: list[TextEdit]) -> str: @ai.tool(require_approval=True) -async def bash(command: str, timeout: float | None = None) -> ai.StreamingTextTool: +async def bash( + command: str, timeout: float | None = None +) -> ai.StreamingTextTool: """Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to the last 2000 @@ -415,11 +421,16 @@ async def bash(command: str, timeout: float | None = None) -> ai.StreamingTextTo buf: list[str] = [] timed_out = False deadline = ( - asyncio.get_event_loop().time() + timeout if timeout is not None else None + asyncio.get_event_loop().time() + timeout + if timeout is not None + else None ) try: async for raw in proc.stdout: - if deadline is not None and asyncio.get_event_loop().time() > deadline: + if ( + deadline is not None + and asyncio.get_event_loop().time() > deadline + ): raise TimeoutError line = raw.decode("utf-8", errors="replace") buf.append(line) @@ -528,9 +539,13 @@ def _short(line: str) -> str: continue if context > 0: ctx_chunks = [] - for j in range(max(0, i - context), min(len(lines), i + context + 1)): + for j in range( + max(0, i - context), min(len(lines), i + context + 1) + ): sep = ":" if j == i else "-" - ctx_chunks.append(f"{rel_path}{sep}{j + 1}{sep}{_short(lines[j])}") + ctx_chunks.append( + f"{rel_path}{sep}{j + 1}{sep}{_short(lines[j])}" + ) entry = "\n".join(ctx_chunks) else: entry = f"{rel_path}:{i + 1}:{_short(line)}" From b2cf64a496a7ec9556085702d98a8f040f590bb8 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Wed, 20 May 2026 18:11:34 -0700 Subject: [PATCH 53/73] [tau] Add multi-line input to composer Ctrl+J inserts a newline (works on all terminals). Trailing backslash before Enter also inserts a newline. Shift/Alt+Enter work on terminals with Kitty keyboard protocol support. Co-authored-by: anthropic/claude-opus-4.6, via tau --- examples/tau-agent/tau/app.py | 36 ++++++++++++++++++++++++++++++++--- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/examples/tau-agent/tau/app.py b/examples/tau-agent/tau/app.py index db1e3504..03b69ed3 100644 --- a/examples/tau-agent/tau/app.py +++ b/examples/tau-agent/tau/app.py @@ -561,8 +561,13 @@ def add_bubble(self, role: str, text: str = "") -> Bubble: class Composer(textual.widgets.TextArea): """Multi-line input that grows with its content. - Enter submits. Shift+Enter (or alt+enter, depending on terminal) - inserts a newline. Height tracks the wrapped line count between + Enter submits. Newline shortcuts: + + - Ctrl+J (all terminals) + - Trailing backslash before Enter (all terminals) + - Shift/Alt+Enter (Kitty keyboard protocol) + + Height tracks the wrapped line count between ``MIN_LINES`` and ``MAX_LINES``. """ @@ -587,11 +592,36 @@ def __init__(self, *, placeholder: str = "", id: str | None = None) -> None: def on_mount(self) -> None: self.refresh_height() + def _insert_newline(self) -> None: + """Insert a newline and grow the composer.""" + self.insert("\n") + self.refresh_height() + async def _on_key(self, event: textual.events.Key) -> None: - # Plain enter submits; shift+enter inserts a newline. + # Newline shortcuts: + # - Ctrl+J (LF — works on all terminals) + # - Shift/Alt+Enter (Kitty keyboard protocol) + if event.key in ( + "ctrl+j", + "shift+enter", + "alt+enter", + ): + event.stop() + event.prevent_default() + self._insert_newline() + return + # Plain Enter submits — unless the line ends with \ + # (backslash continuation), in which case strip it and + # insert a newline instead. if event.key == "enter": event.stop() event.prevent_default() + if self.text.endswith("\\"): + # Delete the backslash (cursor is at end) + # then insert a newline in its place. + self.action_delete_left() + self._insert_newline() + return value = self.text self.text = "" self.refresh_height() From 6d60bef21f3efe7e70dc99193724c7985c85a746 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Wed, 20 May 2026 18:35:43 -0700 Subject: [PATCH 54/73] [tau] Add provider web search tool based on model backend Detect the underlying provider from MODEL_ID and add the appropriate server-side web search tool: - anthropic -> anthropic.tools.web_search() - openai/xai -> openai.tools.web_search() Also adds Ctrl+J and backslash newline shortcuts to the composer, and fixes a docstring escape warning. Co-authored-by: anthropic/claude-opus-4.6, via tau --- examples/tau-agent/tau/app.py | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/examples/tau-agent/tau/app.py b/examples/tau-agent/tau/app.py index 03b69ed3..fb567df1 100644 --- a/examples/tau-agent/tau/app.py +++ b/examples/tau-agent/tau/app.py @@ -56,6 +56,29 @@ else None ) + +def _provider_tools(model_id: str) -> list[Any]: + """Return provider-executed tools for the model's backend. + + Anthropic and OpenAI both offer server-side web search. + The gateway passes through provider-specific tools, so we + pick the right one based on the underlying provider. + """ + # Normalise: "gateway:anthropic/..." → "anthropic", + # "anthropic:..." → "anthropic" + mid = model_id.lower() + if mid.startswith("gateway:"): + mid = mid[len("gateway:"):] + provider = mid.split("/")[0].split(":")[0] + + if provider == "anthropic": + from ai.providers.anthropic import tools as ant + return [ant.web_search()] + if provider in ("openai", "xai"): + from ai.providers.openai import tools as oai + return [oai.web_search()] + return [] + _ADVERTISE = os.environ.get("TAU_ADVERTISE", "") == "1" _BASE_SYSTEM_PROMPT = """\ @@ -763,7 +786,9 @@ def __init__( ) -> None: super().__init__() self.model = ai.get_model(MODEL_ID) - self.agent = ai.agent(tools=tools.TOOLS) + self.agent = ai.agent( + tools=[*tools.TOOLS, *_provider_tools(MODEL_ID)], + ) self.session = SessionManager(SYSTEM_PROMPT) # User messages typed while a turn is streaming. Drained one at # a time at the end of each turn so user/assistant alternation From d24adfdc92ebc4efe20c3d133a936f13ac18e052 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Wed, 20 May 2026 18:36:36 -0700 Subject: [PATCH 55/73] [tau] Mention web_search in system prompt when available The model wasn't using web_search because the system prompt explicitly listed only the coding tools. Now it includes a mention of web_search when provider tools are configured. Co-authored-by: anthropic/claude-opus-4.6, via tau --- examples/tau-agent/tau/app.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/examples/tau-agent/tau/app.py b/examples/tau-agent/tau/app.py index fb567df1..c35ff43c 100644 --- a/examples/tau-agent/tau/app.py +++ b/examples/tau-agent/tau/app.py @@ -88,6 +88,10 @@ def _provider_tools(model_id: str) -> list[Any]: You have access to the read, write, edit, bash, grep, find, and ls tools. Mutating tools (write, edit, bash) require operator approval. """ + ( + "You also have a web_search tool for current information.\n" + if _provider_tools(MODEL_ID) + else "" +) + ( f""" When writing or suggesting commit messages, always include a trailer line: From 73eb5cbb20d5f65d2f8dd3c4cac2d09c9d97c06f Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Wed, 20 May 2026 18:53:19 -0700 Subject: [PATCH 56/73] [tau] Show builtin tool events in transcript Display BuiltinToolEnd and BuiltinToolResult events (from provider-executed tools like web_search) in the transcript alongside regular tool call/result output. Also updates system prompt on session resume so new tools and AGENTS.md changes take effect without a fresh session. Co-authored-by: anthropic/claude-opus-4.6, via tau --- examples/tau-agent/tau/app.py | 33 ++++++++++++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/examples/tau-agent/tau/app.py b/examples/tau-agent/tau/app.py index c35ff43c..9890acb0 100644 --- a/examples/tau-agent/tau/app.py +++ b/examples/tau-agent/tau/app.py @@ -274,12 +274,30 @@ async def _run_turn(app: TauApp) -> None: tc = event.tool_call app.show_tool_call(tc.tool_name, tc.tool_args) elif isinstance(event, ai.events.PartialToolCallResult): - app.append_tool_result(event.tool_call_id, str(event.value)) + app.append_tool_result( + event.tool_call_id, str(event.value) + ) elif isinstance(event, ai.events.ToolCallResult): for part in event.results: # Skip if we already streamed this result. - if part.tool_call_id not in app._tool_result_bubbles: - app.show_tool_result(part.result, part.is_error) + if ( + part.tool_call_id + not in app._tool_result_bubbles + ): + app.show_tool_result( + part.result, part.is_error + ) + # -- provider-executed (builtin) tools -- + elif isinstance(event, ai.events.BuiltinToolEnd): + tc = event.tool_call + app.show_tool_call( + tc.tool_name, tc.tool_args + ) + elif isinstance(event, ai.events.BuiltinToolResult): + app.show_tool_result( + event.result.result, + event.result.is_error, + ) elif isinstance(event, ai.events.HookEvent): app.on_hook_event(Hook.from_event(event.hook)) except asyncio.CancelledError: @@ -835,6 +853,15 @@ def _start_new_session(self) -> None: def _restore_session(self, path: pathlib.Path) -> None: self.session.restore(path) + # Replace the persisted system message with the current + # one so it reflects the latest tools and AGENTS.md. + if ( + self.session.messages + and self.session.messages[0].role == "system" + ): + self.session.messages[0] = ai.system_message( + SYSTEM_PROMPT + ) _replay_session(self) self.session.refresh_usage() self._update_usage_display() From 5050f3e54ede0d1c6540f009be35e8a3c6e29c79 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Fri, 22 May 2026 10:54:29 -0700 Subject: [PATCH 57/73] [tau] use raw model name in Co-authored-by trailer MODEL_ID includes the 'gateway:' prefix, which shouldn't appear in commit trailers. Use _raw_model instead to get the user-provided value. Co-authored-by: anthropic/claude-opus-4.6, via tau --- examples/tau-agent/tau/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/tau-agent/tau/app.py b/examples/tau-agent/tau/app.py index 9890acb0..0ba982d5 100644 --- a/examples/tau-agent/tau/app.py +++ b/examples/tau-agent/tau/app.py @@ -95,7 +95,7 @@ def _provider_tools(model_id: str) -> list[Any]: f""" When writing or suggesting commit messages, always include a trailer line: - Co-authored-by: {MODEL_ID}, via tau + Co-authored-by: {_raw_model}, via tau """ if _ADVERTISE else "" From 615733dd5497760be122c1ece89dd292b9022d5d Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Fri, 22 May 2026 11:05:38 -0700 Subject: [PATCH 58/73] [tau] ruff --- examples/tau-agent/tau/app.py | 53 +++++++++++++++-------------------- 1 file changed, 23 insertions(+), 30 deletions(-) diff --git a/examples/tau-agent/tau/app.py b/examples/tau-agent/tau/app.py index 0ba982d5..4192ade0 100644 --- a/examples/tau-agent/tau/app.py +++ b/examples/tau-agent/tau/app.py @@ -68,37 +68,44 @@ def _provider_tools(model_id: str) -> list[Any]: # "anthropic:..." → "anthropic" mid = model_id.lower() if mid.startswith("gateway:"): - mid = mid[len("gateway:"):] + mid = mid[len("gateway:") :] provider = mid.split("/")[0].split(":")[0] if provider == "anthropic": from ai.providers.anthropic import tools as ant + return [ant.web_search()] if provider in ("openai", "xai"): from ai.providers.openai import tools as oai + return [oai.web_search()] return [] + _ADVERTISE = os.environ.get("TAU_ADVERTISE", "") == "1" -_BASE_SYSTEM_PROMPT = """\ +_BASE_SYSTEM_PROMPT = ( + """\ You are tau, a focused coding assistant running inside a terminal TUI. Keep replies concise and use code blocks when showing code. You have access to the read, write, edit, bash, grep, find, and ls tools. Mutating tools (write, edit, bash) require operator approval. -""" + ( - "You also have a web_search tool for current information.\n" - if _provider_tools(MODEL_ID) - else "" -) + ( - f""" +""" + + ( + "You also have a web_search tool for current information.\n" + if _provider_tools(MODEL_ID) + else "" + ) + + ( + f""" When writing or suggesting commit messages, always include a trailer line: Co-authored-by: {_raw_model}, via tau """ - if _ADVERTISE - else "" + if _ADVERTISE + else "" + ) ) @@ -274,25 +281,16 @@ async def _run_turn(app: TauApp) -> None: tc = event.tool_call app.show_tool_call(tc.tool_name, tc.tool_args) elif isinstance(event, ai.events.PartialToolCallResult): - app.append_tool_result( - event.tool_call_id, str(event.value) - ) + app.append_tool_result(event.tool_call_id, str(event.value)) elif isinstance(event, ai.events.ToolCallResult): for part in event.results: # Skip if we already streamed this result. - if ( - part.tool_call_id - not in app._tool_result_bubbles - ): - app.show_tool_result( - part.result, part.is_error - ) + if part.tool_call_id not in app._tool_result_bubbles: + app.show_tool_result(part.result, part.is_error) # -- provider-executed (builtin) tools -- elif isinstance(event, ai.events.BuiltinToolEnd): tc = event.tool_call - app.show_tool_call( - tc.tool_name, tc.tool_args - ) + app.show_tool_call(tc.tool_name, tc.tool_args) elif isinstance(event, ai.events.BuiltinToolResult): app.show_tool_result( event.result.result, @@ -855,13 +853,8 @@ def _restore_session(self, path: pathlib.Path) -> None: self.session.restore(path) # Replace the persisted system message with the current # one so it reflects the latest tools and AGENTS.md. - if ( - self.session.messages - and self.session.messages[0].role == "system" - ): - self.session.messages[0] = ai.system_message( - SYSTEM_PROMPT - ) + if self.session.messages and self.session.messages[0].role == "system": + self.session.messages[0] = ai.system_message(SYSTEM_PROMPT) _replay_session(self) self.session.refresh_usage() self._update_usage_display() From 3f9455d9cb2199fe3fafeaa892fd471325ce7603 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Fri, 22 May 2026 11:07:28 -0700 Subject: [PATCH 59/73] [tau] fix mypy type errors - Import detect_image_media_type from ai.types.media instead of ai.messages.media (not an explicit export). - Rename shadowed variable to avoid ToolCallPart/BuiltinToolCallPart type conflict. - Guard against None tool_call_id in PartialToolCallResult. - Add mypy to dev dependencies. Co-authored-by: anthropic/claude-opus-4.6, via tau --- examples/tau-agent/pyproject.toml | 1 + examples/tau-agent/tau/app.py | 9 +- examples/tau-agent/tau/tools.py | 2 +- examples/tau-agent/uv.lock | 570 +++++++++--------------------- 4 files changed, 175 insertions(+), 407 deletions(-) diff --git a/examples/tau-agent/pyproject.toml b/examples/tau-agent/pyproject.toml index afa32256..b2ef1650 100644 --- a/examples/tau-agent/pyproject.toml +++ b/examples/tau-agent/pyproject.toml @@ -30,5 +30,6 @@ select = ["E", "F", "I", "UP", "B", "SIM"] [dependency-groups] dev = [ + "mypy~=2.1.0", "ruff>=0.15.12", ] diff --git a/examples/tau-agent/tau/app.py b/examples/tau-agent/tau/app.py index 4192ade0..d9ca3a07 100644 --- a/examples/tau-agent/tau/app.py +++ b/examples/tau-agent/tau/app.py @@ -281,7 +281,10 @@ async def _run_turn(app: TauApp) -> None: tc = event.tool_call app.show_tool_call(tc.tool_name, tc.tool_args) elif isinstance(event, ai.events.PartialToolCallResult): - app.append_tool_result(event.tool_call_id, str(event.value)) + if event.tool_call_id is not None: + app.append_tool_result( + event.tool_call_id, str(event.value) + ) elif isinstance(event, ai.events.ToolCallResult): for part in event.results: # Skip if we already streamed this result. @@ -289,8 +292,8 @@ async def _run_turn(app: TauApp) -> None: app.show_tool_result(part.result, part.is_error) # -- provider-executed (builtin) tools -- elif isinstance(event, ai.events.BuiltinToolEnd): - tc = event.tool_call - app.show_tool_call(tc.tool_name, tc.tool_args) + btc = event.tool_call + app.show_tool_call(btc.tool_name, btc.tool_args) elif isinstance(event, ai.events.BuiltinToolResult): app.show_tool_result( event.result.result, diff --git a/examples/tau-agent/tau/tools.py b/examples/tau-agent/tau/tools.py index 90d9f604..69c7319c 100644 --- a/examples/tau-agent/tau/tools.py +++ b/examples/tau-agent/tau/tools.py @@ -41,7 +41,7 @@ def _detect_image_mime(path: pathlib.Path) -> str | None: header = path.read_bytes()[:32] except OSError: return None - mime = ai.messages.media.detect_image_media_type(header) + mime = ai.types.media.detect_image_media_type(header) return mime if mime in _IMAGE_MIME_TYPES else None diff --git a/examples/tau-agent/uv.lock b/examples/tau-agent/uv.lock index 645da048..3b192ac9 100644 --- a/examples/tau-agent/uv.lock +++ b/examples/tau-agent/uv.lock @@ -1,13 +1,16 @@ version = 1 revision = 3 requires-python = ">=3.12" +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version < '3.15'", +] [[package]] name = "ai" source = { editable = "../../" } dependencies = [ { name = "httpx" }, - { name = "mcp" }, { name = "modelsdotdev" }, { name = "pydantic" }, { name = "typing-extensions" }, @@ -17,18 +20,19 @@ dependencies = [ requires-dist = [ { name = "anthropic", marker = "extra == 'anthropic'", specifier = ">=0.83.0" }, { name = "httpx", specifier = ">=0.28.1" }, - { name = "mcp", specifier = ">=1.18.0" }, + { name = "mcp", marker = "extra == 'mcp'", specifier = ">=1.18.0" }, { name = "modelsdotdev", specifier = "==0.*" }, { name = "openai", marker = "extra == 'openai'", specifier = ">=2.14.0" }, { name = "pydantic", specifier = ">=2.12.5" }, { name = "typing-extensions", specifier = ">=4.15.0" }, ] -provides-extras = ["anthropic", "openai"] +provides-extras = ["anthropic", "mcp", "openai"] [package.metadata.requires-dev] dev = [ { name = "anthropic", specifier = ">=0.83.0" }, { name = "async-solipsism", specifier = ">=0.9" }, + { name = "mcp", specifier = ">=1.18.0" }, { name = "mypy", specifier = "~=2.1.0" }, { name = "openai", specifier = ">=2.14.0" }, { name = "pytest", specifier = ">=8.0" }, @@ -68,12 +72,43 @@ wheels = [ ] [[package]] -name = "attrs" -version = "26.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +name = "ast-serialize" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/81/9d/09e27731bd5864a9ce04e3244074e674bb8936bf62b45e0357248717adac/ast_serialize-0.5.0.tar.gz", hash = "sha256:5880091bfe6f4f986f22866375c2e884843e7a0b6343ae41aeea659613d879b6", size = 61157, upload-time = "2026-05-17T17:48:29.429Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/9a/13dde51ba9e15f8b97957ab7cb0120d0e381524d651c6bd630b9c359227f/ast_serialize-0.5.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8f5c14f169eb0972c0c21bada5358b23d6047c76583b005234f865b11f1fa00a", size = 1183520, upload-time = "2026-05-17T17:47:30.831Z" }, + { url = "https://files.pythonhosted.org/packages/37/de/5a7f0a9fe68944f536632a5af84676739c7d2582be42deb082634bf3a754/ast_serialize-0.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7d1a2de9de5be04652f0ed60738356ef94f66db37924a9499fffe98dc491aa0b", size = 1175779, upload-time = "2026-05-17T17:47:32.551Z" }, + { url = "https://files.pythonhosted.org/packages/9c/81/0bb853e76e4f6e9a1855d569003c59e19ffac45f7079d91505d1bb212f92/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be5173fb66f9b49026d9d5a2ff0fc7c7009077107c0eb285b2d60fdf1fe10bd1", size = 1233750, upload-time = "2026-05-17T17:47:34.731Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d3/4cf705beeccc08754d0bbda99aefff26110e209b9a07ac8a6b60eec48531/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f8015cd071ac1339924ee2b8098c93e00e155f30a16f40ec9816fcf84f4753f6", size = 1235942, upload-time = "2026-05-17T17:47:36.287Z" }, + { url = "https://files.pythonhosted.org/packages/26/c8/ee097e437ea27dd2b8b227865c875492b585650a5802a22d82b304c8201b/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5499e8797edff2a9186aa313ed382c6b422e798e9332d9953badcee6e69a88f2", size = 1442517, upload-time = "2026-05-17T17:47:38.17Z" }, + { url = "https://files.pythonhosted.org/packages/ff/bd/68063442838f1ba68ec72b5436430bc75b3bb17a1a3c3063f09b0c05ae2b/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6848f2a093fb5548751a9a09bff8fcd229e2bbeb0e3331f391b6ae6d26cd9903", size = 1254081, upload-time = "2026-05-17T17:47:39.826Z" }, + { url = "https://files.pythonhosted.org/packages/50/e2/1e520793bc6a4e4524a6ab022391e827825eaa0c3811828bfdc6852eca26/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:832d4c998e0b091fd60a6d6bceee535483c4d490de9ba85003af835225719261", size = 1259910, upload-time = "2026-05-17T17:47:41.369Z" }, + { url = "https://files.pythonhosted.org/packages/4e/e1/49b60f467979979cfe6913b43948ff25bca971ad0591d181812f163a988e/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:16db7c62ec0b8efe1d7afd283a388d8f74f2605d56032e5a37747d2de8dba027", size = 1250678, upload-time = "2026-05-17T17:47:43.702Z" }, + { url = "https://files.pythonhosted.org/packages/74/ba/66ab9555de6275677566f6574e5ef6c29cb185ea866f643bc06f8280a8ee/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:baf5eb061eb5bccade4128ad42da33787d72f6013809cd1b590376ece8b3c937", size = 1301603, upload-time = "2026-05-17T17:47:46.256Z" }, + { url = "https://files.pythonhosted.org/packages/66/42/6aca9b9abc710014b2be9059689e5dd1679339e78f567ffb4d255a9e2050/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:104e4a35bd7c124173c41760ef9aaea17ddb3f86c65cb643671d59afbe3ee94c", size = 1410332, upload-time = "2026-05-17T17:47:47.899Z" }, + { url = "https://files.pythonhosted.org/packages/47/68/2f76594432a22581ecf878b5e75a9b8601c24b2241cf0bbeb1e21fcf370c/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:36be371028fc1675acb38a331bde160dbab7ff907fdf00b67eb6911aa106951b", size = 1509979, upload-time = "2026-05-17T17:47:50.942Z" }, + { url = "https://files.pythonhosted.org/packages/40/ac/a93c9b58292653f6c595752f677a08e608f903b710594909e9231a389b3b/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:061ee58bdb52341c8201a6df41182a977736bae3b7ded87ca7176ca25a8a47ab", size = 1505002, upload-time = "2026-05-17T17:47:54.093Z" }, + { url = "https://files.pythonhosted.org/packages/14/2e/b278f68c497ee2f1d1576cbbef8db5281cd4a5f2db040537592ac9c8862e/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b15219e9cdc9f53f6f4cb51c009203507228226148c05c5e8fe451c28b435eb3", size = 1456231, upload-time = "2026-05-17T17:47:56.311Z" }, + { url = "https://files.pythonhosted.org/packages/0b/43/419be1c566a4c504cd8fd60ce2f84e790f295495c0f327cfaeadf3d51012/ast_serialize-0.5.0-cp314-cp314t-win32.whl", hash = "sha256:842d1c004bb466c7df036f95fabef789570541922b10976b12f5592a69cf0b38", size = 1058668, upload-time = "2026-05-17T17:47:58.305Z" }, + { url = "https://files.pythonhosted.org/packages/03/6f/c9d4d549295ed05111aeb8853232d1afd9d0a179fddb01eeffbb3a4a6842/ast_serialize-0.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b0c06d760909b095cc466356dfccd05a1c7233a6ca191c020dca2c6a6f16c24c", size = 1101075, upload-time = "2026-05-17T17:48:00.35Z" }, + { url = "https://files.pythonhosted.org/packages/d0/8e/d00c5ab30c58222e07d62956fca86c59d91b9ad32997e633c38b526623a3/ast_serialize-0.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:787baedb0262cc49e8ce37cc15c00ae818e46a165a3b36f5e21ed174998104cb", size = 1075347, upload-time = "2026-05-17T17:48:01.753Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9e/dc2530acb3a60dc6e46d65abf27d1d9f86721694757906a148d90a6860de/ast_serialize-0.5.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:0668aa9459cfa8c9c49ddd2163ebcf43088ba045ef7492af6fe22e0098303101", size = 1191380, upload-time = "2026-05-17T17:48:03.738Z" }, + { url = "https://files.pythonhosted.org/packages/26/0a/bd3d18a582f273d6c843d16bb9e22e9e16365ff7991e92f18f798e9f1224/ast_serialize-0.5.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:bf683d6363edf2b39eed6b6d4fe22d34b6203867a67e27134d9e2a2680c4bc4a", size = 1183879, upload-time = "2026-05-17T17:48:05.463Z" }, + { url = "https://files.pythonhosted.org/packages/40/ae/1f919100f8620887af58fcc381c61a1f218cdf89c6e155f87b213e61010a/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cc22cf0c9be65e71cf88fda130af60d61eb4a79370ad4cfe7900d48a4aa2211", size = 1244529, upload-time = "2026-05-17T17:48:07.008Z" }, + { url = "https://files.pythonhosted.org/packages/c6/ca/6376559dcce707cdbc1d0d9a13c8d3baaaa501e949ce0ebdc4230cd881aa/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f66173891548c9f2726bf27957b41cabce12fa679dc6da505ddbde4d4b3b31cf", size = 1240560, upload-time = "2026-05-17T17:48:08.46Z" }, + { url = "https://files.pythonhosted.org/packages/35/b2/a620e206b5aeb7efbf2710336df57d457cffbb3991076bbcc1147ef9abd4/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e42d729ef2be96a14efbad355093284739e3670ece3e534f82cc8832790911d9", size = 1451172, upload-time = "2026-05-17T17:48:09.922Z" }, + { url = "https://files.pythonhosted.org/packages/fa/e0/4ad5c04c24a40481b2935ce9a0ccdb6023dc8b667167d06ae530cc3512f2/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b725026bafa801dbd7310eb13a75f0a2e370e7e51b2cb225f9d21fcfadf919ee", size = 1265072, upload-time = "2026-05-17T17:48:11.469Z" }, + { url = "https://files.pythonhosted.org/packages/b2/71/4d1d479aa56d0101c40e17720c3d6ac2af7269ea0487a80b18e7bfd1a5b7/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b54f60c1d78767a53b67eaa663f0dfac3afe606aa07f1301572f588b73d64809", size = 1270488, upload-time = "2026-05-17T17:48:13.575Z" }, + { url = "https://files.pythonhosted.org/packages/6d/4f/0de1bbe06f6edef9fde4ed12ca8e7b3ec7e6e2bd4e672c5af487f7957665/ast_serialize-0.5.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:27d51654fc240a1e87e742d353d98eb45b75f62f129086b3596ab53df2ac2a43", size = 1260702, upload-time = "2026-05-17T17:48:15.141Z" }, + { url = "https://files.pythonhosted.org/packages/75/61/e00872439cfdddcc3c1b6cdaa6e5d904ba8e26a18807c67c4e14409d0ca8/ast_serialize-0.5.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c36237c46dd1674542f2109740ea5ea485a169bf1431939ada0434e17934", size = 1311182, upload-time = "2026-05-17T17:48:16.779Z" }, + { url = "https://files.pythonhosted.org/packages/76/8e/699a5b955f7926956c95e9e1d74132acad73c2fe7a426f94da89123c20aa/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1943db345233cc7194a470f13afa9c59772c0b123dea0c9414c4d4ca54369759", size = 1421410, upload-time = "2026-05-17T17:48:18.527Z" }, + { url = "https://files.pythonhosted.org/packages/a9/ae/d5b7626874478997adc7a29ab28accf21e596fb590c944290401dfd0b29e/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:df1c00022cbbcb064bfaa505aa9c9295362443ce5dacb459d1331d3da353f887", size = 1516587, upload-time = "2026-05-17T17:48:20.133Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ce/b59e02a82d9c4244d64cde502e0b00e83e38816abe19155ceb5437402c7f/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:cae65289fc456fde04af979a2be09302ef5d8ab92ef23e596d6746dc267ada27", size = 1515171, upload-time = "2026-05-17T17:48:21.921Z" }, + { url = "https://files.pythonhosted.org/packages/8b/38/d8d90042747d05aa08d4efcf1c99035a5f670a6bf4c214d31644392afbca/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:239a4c354e8d676e9d94631d1d4a64edc6b266f86ff3a5a80aedd344f342c01d", size = 1464668, upload-time = "2026-05-17T17:48:23.544Z" }, + { url = "https://files.pythonhosted.org/packages/dd/51/5b840c4df7334104cecffa28f23904fe81ca89ca223d2450e288de39fd3c/ast_serialize-0.5.0-cp39-abi3-win32.whl", hash = "sha256:143a4ef63285a075871908fda3672dc21864b83a8ec3ee12304aa3e4c5387b9a", size = 1068311, upload-time = "2026-05-17T17:48:25.027Z" }, + { url = "https://files.pythonhosted.org/packages/41/11/ca5672c7d491825bc4cd6702dea106a6b60d928707712ec257c7833ae476/ast_serialize-0.5.0-cp39-abi3-win_amd64.whl", hash = "sha256:cf25572c526add400f26a4750dc6ce0c3bb93fc1f75e7ae0cad4ce4f2cd5c590", size = 1108931, upload-time = "2026-05-17T17:48:26.591Z" }, + { url = "https://files.pythonhosted.org/packages/45/19/cc8bd127d28a43da249aa955cfd164cf8fd534e79e42cea96c4854d72fd0/ast_serialize-0.5.0-cp39-abi3-win_arm64.whl", hash = "sha256:92a31c9c20d25a076edaeec76b128a3535d74a24f340b9a8a7e96c9b86dc9642", size = 1081181, upload-time = "2026-05-17T17:48:28.122Z" }, ] [[package]] @@ -85,137 +120,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" }, ] -[[package]] -name = "cffi" -version = "2.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pycparser", marker = "implementation_name != 'PyPy'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, - { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, - { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, - { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, - { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, - { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, - { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, - { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, - { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, - { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, - { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, - { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, - { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, - { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, - { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, - { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, - { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, - { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, - { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, - { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, - { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, - { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, - { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, - { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, - { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, - { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, - { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, - { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, - { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, - { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, - { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, - { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, - { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, - { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, - { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, - { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, - { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, - { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, - { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, - { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, - { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, - { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, - { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, - { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, - { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, - { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, -] - -[[package]] -name = "click" -version = "8.3.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/bb/63/f9e1ea081ce35720d8b92acde70daaedace594dc93b693c869e0d5910718/click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2", size = 328061, upload-time = "2026-04-22T15:11:27.506Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502, upload-time = "2026-04-22T15:11:25.044Z" }, -] - -[[package]] -name = "colorama" -version = "0.4.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, -] - -[[package]] -name = "cryptography" -version = "48.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/9f/a9/db8f313fdcd85d767d4973515e1db101f9c71f95fced83233de224673757/cryptography-48.0.0.tar.gz", hash = "sha256:5c3932f4436d1cccb036cb0eaef46e6e2db91035166f1ad6505c3c9d5a635920", size = 832984, upload-time = "2026-05-04T22:59:38.133Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/df/3d/01f6dd9190170a5a241e0e98c2d04be3664a9e6f5b9b872cde63aff1c3dd/cryptography-48.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:0c558d2cdffd8f4bbb30fc7134c74d2ca9a476f830bb053074498fbc86f41ed6", size = 8001587, upload-time = "2026-05-04T22:57:36.803Z" }, - { url = "https://files.pythonhosted.org/packages/b2/6e/e90527eef33f309beb811cf7c982c3aeffcce8e3edb178baa4ca3ae4a6fa/cryptography-48.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f5333311663ea94f75dd408665686aaf426563556bb5283554a3539177e03b8c", size = 4690433, upload-time = "2026-05-04T22:57:40.373Z" }, - { url = "https://files.pythonhosted.org/packages/90/04/673510ed51ddff56575f306cf1617d80411ee76831ccd3097599140efdfe/cryptography-48.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7995ef305d7165c3f11ae07f2517e5a4f1d5c18da1376a0a9ed496336b69e5f3", size = 4710620, upload-time = "2026-05-04T22:57:42.935Z" }, - { url = "https://files.pythonhosted.org/packages/14/d5/e9c4ef932c8d800490c34d8bd589d64a31d5890e27ec9e9ad532be893294/cryptography-48.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:40ba1f85eaa6959837b1d51c9767e230e14612eea4ef110ee8854ada22da1bf5", size = 4696283, upload-time = "2026-05-04T22:57:45.294Z" }, - { url = "https://files.pythonhosted.org/packages/0c/29/174b9dfb60b12d59ecfc6cfa04bc88c21b42a54f01b8aae09bb6e51e4c7f/cryptography-48.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:369a6348999f94bbd53435c894377b20ab95f25a9065c283570e70150d8abc3c", size = 5296573, upload-time = "2026-05-04T22:57:47.933Z" }, - { url = "https://files.pythonhosted.org/packages/95/38/0d29a6fd7d0d1373f0c0c88a04ba20e359b257753ac497564cd660fc1d55/cryptography-48.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a0e692c683f4df67815a2d258b324e66f4738bd7a96a218c826dce4f4bd05d8f", size = 4743677, upload-time = "2026-05-04T22:57:50.067Z" }, - { url = "https://files.pythonhosted.org/packages/30/be/eef653013d5c63b6a490529e0316f9ac14a37602965d4903efed1399f32b/cryptography-48.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:18349bbc56f4743c8b12dc32e2bccb2cf83ee8b69a3bba74ef8ae857e26b3d25", size = 4330808, upload-time = "2026-05-04T22:57:52.301Z" }, - { url = "https://files.pythonhosted.org/packages/84/9e/500463e87abb7a0a0f9f256ec21123ecde0a7b5541a15e840ea54551fd81/cryptography-48.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e8eac43dfca5c4cccc6dad9a80504436fca53bb9bc3100a2386d730fbe6b602", size = 4695941, upload-time = "2026-05-04T22:57:54.603Z" }, - { url = "https://files.pythonhosted.org/packages/e3/dc/7303087450c2ec9e7fbb750e17c2abfbc658f23cbd0e54009509b7cc4091/cryptography-48.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9ccdac7d40688ecb5a3b4a604b8a88c8002e3442d6c60aead1db2a89a041560c", size = 5252579, upload-time = "2026-05-04T22:57:57.207Z" }, - { url = "https://files.pythonhosted.org/packages/d0/c0/7101d3b7215edcdc90c45da544961fd8ed2d6448f77577460fa75a8443f7/cryptography-48.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:bd72e68b06bb1e96913f97dd4901119bc17f39d4586a5adf2d3e47bc2b9d58b5", size = 4743326, upload-time = "2026-05-04T22:57:59.535Z" }, - { url = "https://files.pythonhosted.org/packages/ac/d8/5b833bad13016f562ab9d063d68199a4bd121d18458e439515601d3357ec/cryptography-48.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:59baa2cb386c4f0b9905bd6eb4c2a79a69a128408fd31d32ca4d7102d4156321", size = 4826672, upload-time = "2026-05-04T22:58:01.996Z" }, - { url = "https://files.pythonhosted.org/packages/98/e1/7074eb8bf3c135558c73fc2bcf0f5633f912e6fb87e868a55c454080ef09/cryptography-48.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9249e3cd978541d665967ac2cb2787fd6a62bddf1e75b3e347a594d7dacf4f74", size = 4972574, upload-time = "2026-05-04T22:58:03.968Z" }, - { url = "https://files.pythonhosted.org/packages/04/70/e5a1b41d325f797f39427aa44ef8baf0be500065ab6d8e10369d850d4a4f/cryptography-48.0.0-cp311-abi3-win32.whl", hash = "sha256:9c459db21422be75e2809370b829a87eb37f74cd785fc4aa9ea1e5f43b47cda4", size = 3294868, upload-time = "2026-05-04T22:58:06.467Z" }, - { url = "https://files.pythonhosted.org/packages/f4/ac/8ac51b4a5fc5932eb7ee5c517ba7dc8cd834f0048962b6b352f00f41ebf9/cryptography-48.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:5b012212e08b8dd5edc78ef54da83dd9892fd9105323b3993eff6bea65dc21d7", size = 3817107, upload-time = "2026-05-04T22:58:08.845Z" }, - { url = "https://files.pythonhosted.org/packages/6b/84/70e3feea9feea87fd7cbe77efb2712ae1e3e6edf10749dc6e95f4e60e455/cryptography-48.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:3cb07a3ed6431663cd321ea8a000a1314c74211f823e4177fefa2255e057d1ec", size = 7986556, upload-time = "2026-05-04T22:58:11.172Z" }, - { url = "https://files.pythonhosted.org/packages/89/6e/18e07a618bb5442ba10cf4df16e99c071365528aa570dfcb8c02e25a303b/cryptography-48.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c7378637d7d88016fa6791c159f698b3d3eed28ebf844ac36b9dc04a14dae18", size = 4684776, upload-time = "2026-05-04T22:58:13.712Z" }, - { url = "https://files.pythonhosted.org/packages/be/6a/4ea3b4c6c6759794d5ee2103c304a5076dc4b19ae1f9fe47dba439e159e9/cryptography-48.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc90c0b39b2e3c65ef52c804b72e3c58f8a04ab2a1871272798e5f9572c17d20", size = 4698121, upload-time = "2026-05-04T22:58:16.448Z" }, - { url = "https://files.pythonhosted.org/packages/2f/59/6ff6ad6cae03bb887da2a5860b2c9805f8dac969ef01ce563336c49bd1d1/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:76341972e1eff8b4bea859f09c0d3e64b96ce931b084f9b9b7db8ef364c30eff", size = 4690042, upload-time = "2026-05-04T22:58:18.544Z" }, - { url = "https://files.pythonhosted.org/packages/ca/b4/fc334ed8cfd705aca282fe4d8f5ae64a8e0f74932e9feecb344610cf6e4d/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:55b7718303bf06a5753dcdccf2f3945cf18ad7bffde41b61226e4db31ab89a9c", size = 5282526, upload-time = "2026-05-04T22:58:20.75Z" }, - { url = "https://files.pythonhosted.org/packages/11/08/9f8c5386cc4cd90d8255c7cdd0f5baf459a08502a09de30dc51f553d38dc/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:a64697c641c7b1b2178e573cbc31c7c6684cd56883a478d75143dbb7118036db", size = 4733116, upload-time = "2026-05-04T22:58:23.627Z" }, - { url = "https://files.pythonhosted.org/packages/b8/77/99307d7574045699f8805aa500fa0fb83422d115b5400a064ddd306d7750/cryptography-48.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:561215ea3879cb1cbbf272867e2efda62476f240fb58c64de6b393ae19246741", size = 4316030, upload-time = "2026-05-04T22:58:25.581Z" }, - { url = "https://files.pythonhosted.org/packages/fd/36/a608b98337af3cb2aff4818e406649d30572b7031918b04c87d979495348/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ad64688338ed4bc1a6618076ba75fd7194a5f1797ac60b47afe926285adb3166", size = 4689640, upload-time = "2026-05-04T22:58:27.747Z" }, - { url = "https://files.pythonhosted.org/packages/dd/a6/825010a291b4438aecc1f568bc428189fc1175515223632477c07dc0a6df/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:906cbf0670286c6e0044156bc7d4af9cbb0ef6db9f73e52c3ec56ba6bdde5336", size = 5237657, upload-time = "2026-05-04T22:58:29.848Z" }, - { url = "https://files.pythonhosted.org/packages/b9/09/4e76a09b4caa29aad535ddc806f5d4c5d01885bd978bd984fbc6ca032cae/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:ea8990436d914540a40ab24b6a77c0969695ed52f4a4874c5137ccf7045a7057", size = 4732362, upload-time = "2026-05-04T22:58:32.009Z" }, - { url = "https://files.pythonhosted.org/packages/18/78/444fa04a77d0cb95f417dda20d450e13c56ba8e5220fc892a1658f44f882/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c18684a7f0cc9a3cb60328f496b8e3372def7c5d2df39ac267878b05565aaaae", size = 4819580, upload-time = "2026-05-04T22:58:34.254Z" }, - { url = "https://files.pythonhosted.org/packages/38/85/ea67067c70a1fd4be2c63d35eeed82658023021affccc7b17705f8527dd2/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9be5aafa5736574f8f15f262adc81b2a9869e2cfe9014d52a44633905b40d52c", size = 4963283, upload-time = "2026-05-04T22:58:36.376Z" }, - { url = "https://files.pythonhosted.org/packages/75/54/cc6d0f3deac3e81c7f847e8a189a12b6cdd65059b43dad25d4316abd849a/cryptography-48.0.0-cp314-cp314t-win32.whl", hash = "sha256:c17dfe85494deaeddc5ce251aebd1d60bbe6afc8b62071bb0b469431a000124f", size = 3270954, upload-time = "2026-05-04T22:58:38.791Z" }, - { url = "https://files.pythonhosted.org/packages/49/67/cc947e288c0758a4e5473d1dcb743037ab7785541265a969240b8885441a/cryptography-48.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27241b1dc9962e056062a8eef1991d02c3a24569c95975bd2322a8a52c6e5e12", size = 3797313, upload-time = "2026-05-04T22:58:40.746Z" }, - { url = "https://files.pythonhosted.org/packages/f2/63/61d4a4e1c6b6bab6ce1e213cd36a24c415d90e76d78c5eb8577c5541d2e8/cryptography-48.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:58d00498e8933e4a194f3076aee1b4a97dfec1a6da444535755822fe5d8b0b86", size = 7983482, upload-time = "2026-05-04T22:58:43.769Z" }, - { url = "https://files.pythonhosted.org/packages/d5/ac/f5b5995b87770c693e2596559ffafe195b4033a57f14a82268a2842953f3/cryptography-48.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:614d0949f4790582d2cc25553abd09dd723025f0c0e7c67376a1d77196743d6e", size = 4683266, upload-time = "2026-05-04T22:58:46.064Z" }, - { url = "https://files.pythonhosted.org/packages/ec/c6/8b14f67e18338fbc4adb76f66c001f5c3610b3e2d1837f268f47a347dbbb/cryptography-48.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ce4bfae76319a532a2dc68f82cc32f5676ee792a983187dac07183690e5c66f", size = 4696228, upload-time = "2026-05-04T22:58:48.22Z" }, - { url = "https://files.pythonhosted.org/packages/ea/73/f808fbae9514bd91b47875b003f13e284c8c6bdfd904b7944e803937eec1/cryptography-48.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:2eb992bbd4661238c5a397594c83f5b4dc2bc5b848c365c8f991b6780efcc5c7", size = 4689097, upload-time = "2026-05-04T22:58:50.9Z" }, - { url = "https://files.pythonhosted.org/packages/93/01/d86632d7d28db8ae83221995752eeb6639ffb374c2d22955648cf8d52797/cryptography-48.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:22a5cb272895dce158b2cacdfdc3debd299019659f42947dbdac6f32d68fe832", size = 5283582, upload-time = "2026-05-04T22:58:53.017Z" }, - { url = "https://files.pythonhosted.org/packages/02/e1/50edc7a50334807cc4791fc4a0ce7468b4a1416d9138eab358bfc9a3d70b/cryptography-48.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2b4d59804e8408e2fea7d1fbaf218e5ec984325221db76e6a241a9abd6cdd95c", size = 4730479, upload-time = "2026-05-04T22:58:55.611Z" }, - { url = "https://files.pythonhosted.org/packages/6f/af/99a582b1b1641ff5911ac559beb45097cf79efd4ead4657f578ef1af2d47/cryptography-48.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:984a20b0f62a26f48a3396c72e4bc34c66e356d356bf370053066b3b6d54634a", size = 4326481, upload-time = "2026-05-04T22:58:57.607Z" }, - { url = "https://files.pythonhosted.org/packages/90/ee/89aa26a06ef0a7d7611788ffd571a7c50e368cc6a4d5eef8b4884e866edb/cryptography-48.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5a5ed8fde7a1d09376ca0b40e68cd59c69fe23b1f9768bd5824f54681626032a", size = 4688713, upload-time = "2026-05-04T22:59:00.077Z" }, - { url = "https://files.pythonhosted.org/packages/70/ba/bcb1b0bb7a33d4c7c0c4d4c7874b4a62ae4f56113a5f4baefa362dfb1f0f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:8cd666227ef7af430aa5914a9910e0ddd703e75f039cef0825cd0da71b6b711a", size = 5238165, upload-time = "2026-05-04T22:59:02.317Z" }, - { url = "https://files.pythonhosted.org/packages/c9/70/ca4003b1ce5ca3dc3186ada51908c8a9b9ff7d5cab83cc0d43ee14ec144f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9071196d81abc88b3516ac8cdfad32e2b66dd4a5393a8e68a961e9161ddc6239", size = 4729947, upload-time = "2026-05-04T22:59:05.255Z" }, - { url = "https://files.pythonhosted.org/packages/44/a0/4ec7cf774207905aef1a8d11c3750d5a1db805eb380ee4e16df317870128/cryptography-48.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1e2d54c8be6152856a36f0882ab231e70f8ec7f14e93cf87db8a2ed056bf160c", size = 4822059, upload-time = "2026-05-04T22:59:07.802Z" }, - { url = "https://files.pythonhosted.org/packages/1e/75/a2e55f99c16fcac7b5d6c1eb19ad8e00799854d6be5ca845f9259eae1681/cryptography-48.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a5da777e32ffed6f85a7b2b3f7c5cbc88c146bfcd0a1d7baf5fcc6c52ee35dd4", size = 4960575, upload-time = "2026-05-04T22:59:09.851Z" }, - { url = "https://files.pythonhosted.org/packages/b8/23/6e6f32143ab5d8b36ca848a502c4bcd477ae75b9e1677e3530d669062578/cryptography-48.0.0-cp39-abi3-win32.whl", hash = "sha256:77a2ccbbe917f6710e05ba9adaa25fb5075620bf3ea6fb751997875aff4ae4bd", size = 3279117, upload-time = "2026-05-04T22:59:12.019Z" }, - { url = "https://files.pythonhosted.org/packages/9d/9a/0fea98a70cf1749d41d738836f6349d97945f7c89433a259a6c2642eefeb/cryptography-48.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8", size = 3792100, upload-time = "2026-05-04T22:59:14.884Z" }, -] - [[package]] name = "h11" version = "0.16.0" @@ -253,15 +157,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] -[[package]] -name = "httpx-sse" -version = "0.4.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, -] - [[package]] name = "idna" version = "3.15" @@ -272,30 +167,63 @@ wheels = [ ] [[package]] -name = "jsonschema" -version = "4.26.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "attrs" }, - { name = "jsonschema-specifications" }, - { name = "referencing" }, - { name = "rpds-py" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, -] - -[[package]] -name = "jsonschema-specifications" -version = "2025.9.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "referencing" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +name = "librt" +version = "0.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/40/08/9e7f6b5d2b5bed6ad055cdd5925f192bb403a51280f86b56554d9d0699a2/librt-0.11.0.tar.gz", hash = "sha256:075dc3ef4458a278e0195cbf6ac9d38808d9b906c5a6c7f7f79c3888276a3fb1", size = 200139, upload-time = "2026-05-10T18:17:25.138Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/d0/07c77e067f0838949b43bd89232c29d72efebb9d2801a9750184eb706b71/librt-0.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b87504f1690a23b9a2cca841191a04f83895d4fc2dd04df91d82b1a04ca2ad46", size = 144147, upload-time = "2026-05-10T18:15:53.227Z" }, + { url = "https://files.pythonhosted.org/packages/7a/24/8493538fa4f62f982686398a5b8f68008138a75086abdea19ade64bf4255/librt-0.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40071fc5fe0ce8daa6de616702314a01e1250711682b0523d6ab8d4525910cb3", size = 143614, upload-time = "2026-05-10T18:15:54.657Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1e/f8bad050810d9171f34a1648ed910e56814c2ba61639f2bd53c6377ae24b/librt-0.11.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:137e79445c896a0ea7b265f52d23954e05b64222ee1af69e2cb34219067cbb67", size = 485538, upload-time = "2026-05-10T18:15:56.117Z" }, + { url = "https://files.pythonhosted.org/packages/c0/fe/3594ebfbaf03084ba4b120c9ba5c3183fd938a48725e9bbe6ff0a5159ad8/librt-0.11.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:cca6644054e78746d8d4ef238681f9c34ff8b584fe6b988ecebb8db3b15e622a", size = 479623, upload-time = "2026-05-10T18:15:57.544Z" }, + { url = "https://files.pythonhosted.org/packages/b0/da/5d1876984b3746c85dbd219dbfcb73c85f54ee263fd32e5b2a632ec14571/librt-0.11.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5b0eea49f5562861ee8d757a32ef7d559c1d35be2aaaa1ec28941d74c9ffc8a", size = 513082, upload-time = "2026-05-10T18:15:58.805Z" }, + { url = "https://files.pythonhosted.org/packages/19/6e/55bdf5d5ca00c3e18430690bf2c953d8d3ffd3c337418173d33dec985dc9/librt-0.11.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0d1029d7e1ae1a7e647ed6fb5df8c4ce2dffefb7a9f5fd1376a4554d96dac09f", size = 508105, upload-time = "2026-05-10T18:16:00.2Z" }, + { url = "https://files.pythonhosted.org/packages/07/10/f1f23a7c595ee90ece4d35c851e5d104b1311a887ed1b4ac4c35bbd13da8/librt-0.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bc3ce6b33c5828d9e80592011a5c584cb2ce86edbc4088405f70da47dc1d1b3b", size = 522268, upload-time = "2026-05-10T18:16:01.708Z" }, + { url = "https://files.pythonhosted.org/packages/b6/02/5720f5697a7f54b78b3aefbe20df3a48cedcff1276618c4aa481177942ed/librt-0.11.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:936c5995f3514a42111f20099397d8177c79b4d7e70961e396c6f5a0a3566766", size = 527348, upload-time = "2026-05-10T18:16:03.496Z" }, + { url = "https://files.pythonhosted.org/packages/50/db/b4a47c6f91db4ff76348a0b3dd0cc65e090a078b765a810a62ff9434c3d3/librt-0.11.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:9bc0ca6ad9381cbe8e4aa6e5726e4c80c78115a6e9723c599ed1d73e092bc49d", size = 516294, upload-time = "2026-05-10T18:16:05.173Z" }, + { url = "https://files.pythonhosted.org/packages/9e/58/9384b2f4eb1ed1d273d40948a7c5c4b2360213b402ef3be4641c06299f9c/librt-0.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:070aa8c26c0a74774317a72df8851facc7f0f012a5b406557ac56992d92e1ec8", size = 553608, upload-time = "2026-05-10T18:16:06.839Z" }, + { url = "https://files.pythonhosted.org/packages/21/7b/5aa8848a7c6a9278c79375146da1812e695754ceec5f005e6043461a7315/librt-0.11.0-cp312-cp312-win32.whl", hash = "sha256:6bf14feb84b05ae945277395451998c89c54d0def4070eb5c08de544930b245a", size = 101879, upload-time = "2026-05-10T18:16:08.103Z" }, + { url = "https://files.pythonhosted.org/packages/37/33/8a745436944947575b584231750a41417de1a38cf6a2e9251d1065651c09/librt-0.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:75672f0bc524ede266287d532d7923dbce94c7514ad07627bac3d0c6d92cc4d9", size = 119831, upload-time = "2026-05-10T18:16:09.174Z" }, + { url = "https://files.pythonhosted.org/packages/59/67/a6739ac96e28b7855808bdb0370e250606104a859750d209e5a0716fe7ab/librt-0.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:2f10cf143e4a9bb0f4f5af568a00df94a2d69ef41c2579584454bb0fe5cc642c", size = 103470, upload-time = "2026-05-10T18:16:10.369Z" }, + { url = "https://files.pythonhosted.org/packages/82/61/e59168d4d0bf2bf90f4f0caf7a001bfc60254c3af4586013b04dc3ef517b/librt-0.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:78dc31f7fdfe9c9d0eb0e8f42d139db230e826415bbcabd9f0e9faaaee909894", size = 144119, upload-time = "2026-05-10T18:16:11.771Z" }, + { url = "https://files.pythonhosted.org/packages/61/fd/caa1d60b12f7dd79ccea23054e06eeaebe266a5f52c40a6b651069200ce5/librt-0.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fa475675db22290c3158e1d42326d0f5a65f04f44a0e68c3630a25b53560fb9c", size = 143565, upload-time = "2026-05-10T18:16:13.334Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a9/dc744f5c2b4978d48db970be29f22716d3413d28b14ad99740817315cf2c/librt-0.11.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:621db29691044bdeda22e789e482e1b0f3a985d90e3426c9c6d17606416205ea", size = 485395, upload-time = "2026-05-10T18:16:14.729Z" }, + { url = "https://files.pythonhosted.org/packages/8f/21/7f8e97a1e4dae952a5a95948f6f8507a173bc1e669f54340bba6ca1ca31b/librt-0.11.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:a9010e2ed5b3a9e158c5fd966b3ab7e834bb3d3aacc8f66c91dd4b57a3799230", size = 479383, upload-time = "2026-05-10T18:16:16.321Z" }, + { url = "https://files.pythonhosted.org/packages/a6/6d/d8ee9c114bebf2c50e29ec2aa940826fccb62a645c3e4c18760987d0e16d/librt-0.11.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c39513d8b7477a2e1ed8c43fc21c524e8d5a0f8d4e8b7b074dbdbe7820a08e2", size = 513010, upload-time = "2026-05-10T18:16:17.647Z" }, + { url = "https://files.pythonhosted.org/packages/f0/43/0b5708af2bd30a46400e72ba6bdaa8f066f15fb9a688527e34220e8d6c06/librt-0.11.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7aef3cf1d5af86e770ab04bfd993dfc4ae8b8c17f66fb77dd4a7d50de7bbb1a3", size = 508433, upload-time = "2026-05-10T18:16:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/4a/50/356187247d09013490481033183b3532b58acf8028bcb34b2b56a375c9b2/librt-0.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:557183ddc36babe46b27dd60facbd5adb4492181a5be887587d57cda6e092f21", size = 522595, upload-time = "2026-05-10T18:16:20.642Z" }, + { url = "https://files.pythonhosted.org/packages/40/e7/c6ac4240899c7f3248079d5a9900debe0dadb3fdeaf856684c987105ba47/librt-0.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:83d3e1f72bd42f6c5c0b7daec530c3f829bd02db42c70b8ddf0c2d90a2459930", size = 527255, upload-time = "2026-05-10T18:16:22.352Z" }, + { url = "https://files.pythonhosted.org/packages/eb/b5/a81322dbeedeeaf9c1ee6f001734d28a09d8383ac9e6779bc24bbd0743c6/librt-0.11.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:4ce1f21fbe589bc1afd7872dece84fb0e1144f794a288e58a10d2c54a55c43be", size = 516847, upload-time = "2026-05-10T18:16:23.627Z" }, + { url = "https://files.pythonhosted.org/packages/ae/66/6e6323787d592b55204a42595ff1102da5115601b53a7e9ddebc889a6da5/librt-0.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b09f7044ea2b64c9da42fd3d335666518cfd1c6e8a182c95da73d0214b41e", size = 553920, upload-time = "2026-05-10T18:16:25.025Z" }, + { url = "https://files.pythonhosted.org/packages/9c/21/623f8ca230857102066d9ca8c6c1734995908c4d0d1bee7bb2ef0021cb33/librt-0.11.0-cp313-cp313-win32.whl", hash = "sha256:78fddc31cd4d3caa897ad5d31f856b1faadc9474021ad6cb182b9018793e254e", size = 101898, upload-time = "2026-05-10T18:16:26.649Z" }, + { url = "https://files.pythonhosted.org/packages/b3/1d/b4ebd44dd723f768469007515cb92251e0ae286c94c140f374801140fa74/librt-0.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:8ca8aa88751a775870b764e93bad5135385f563cb8dcee399abf034ea4d3cb47", size = 119812, upload-time = "2026-05-10T18:16:27.859Z" }, + { url = "https://files.pythonhosted.org/packages/3b/e4/b2f4ca7965ca373b491cdb4bc25cdb30c1649ca81a8782056a83850292a9/librt-0.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:96f044bb325fd9cf1a723015638c219e9143f0dfbc0ca54c565df2b7fc748b44", size = 103448, upload-time = "2026-05-10T18:16:29.066Z" }, + { url = "https://files.pythonhosted.org/packages/29/eb/dbce197da4e227779e56b5735f2decc3eb36e55a1cdbf1bd65d6639d76c1/librt-0.11.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4a017a95e5837dc15a8c5661d60e05daa96b90908b1aa6b7acdf443cd25c8ebd", size = 143345, upload-time = "2026-05-10T18:16:30.674Z" }, + { url = "https://files.pythonhosted.org/packages/76/a3/254bebd0c11c8ba684018efb8006ff22e466abce445215cca6c778e7d9de/librt-0.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b1ecbd9819deccc39b7542bf4d2a740d8a620694d39989e58661d3763458f8d4", size = 143131, upload-time = "2026-05-10T18:16:32.037Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3f/f77d6122d21ac7bf6ae8a7dfced1bd2a7ac545d3273ebdcaf8042f6d619f/librt-0.11.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7da327dacd7be8f8ec36547373550744a3cc0e536d54665cd83f8bcd961200e8", size = 477024, upload-time = "2026-05-10T18:16:33.493Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0a/2c996dadebaa7d9bbbd43ef2d4f3e66b6da545f838a41694ef6172cebec8/librt-0.11.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0dc56b1f8d06e60db362cc3fdae206681817f86ce4725d34511473487f12a34b", size = 474221, upload-time = "2026-05-10T18:16:34.864Z" }, + { url = "https://files.pythonhosted.org/packages/0a/7e/f5d92af8486b8272c23b3e686b46ff72d89c8169585eb61eef01a2ac7147/librt-0.11.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05fb8fb2ab90e21c8d12ea240d744ad514da9baf381ebfa70d91d20d21713175", size = 505174, upload-time = "2026-05-10T18:16:36.705Z" }, + { url = "https://files.pythonhosted.org/packages/af/1a/cb0734fe86398eb33193ab753b7326255c74cac5eb09e76b9b16536e7adb/librt-0.11.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cae74872be221df4374d10fec61f93ed1513b9546ea84f2c0bf73ab3e9bd0b03", size = 497216, upload-time = "2026-05-10T18:16:38.418Z" }, + { url = "https://files.pythonhosted.org/packages/18/06/094820f91558b66e29943c0ec41c9914f460f48dd51fc503c3101e10842d/librt-0.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:32bcc918c0148eb7e3d57385125bac7e5f9e4359d05f07448b09f6f778c2f31c", size = 513921, upload-time = "2026-05-10T18:16:39.848Z" }, + { url = "https://files.pythonhosted.org/packages/0b/c2/00de9018871a282f530cacb457d5ec0428f6ac7e6fedde9aff7468d9fb04/librt-0.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f9743fc99135d5f78d2454435615f6dec0473ca507c26ce9d92b10b562a280d3", size = 520850, upload-time = "2026-05-10T18:16:41.471Z" }, + { url = "https://files.pythonhosted.org/packages/51/9d/64631832348fd1834fb3a61b996434edddaaf25a31d03b0a76273159d2cf/librt-0.11.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5ba067f4aadae8fda802d91d2124c90c42195ff32d9161d3549e6d05cfe26f96", size = 504237, upload-time = "2026-05-10T18:16:43.15Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ec/ae5525eb16edc827a044e7bb8777a455ff95d4bca9379e7e6bddd7383647/librt-0.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:de3bf945454d032f9e390b85c4072e0a0570bf825421c8be0e71209fa65e1abe", size = 546261, upload-time = "2026-05-10T18:16:44.408Z" }, + { url = "https://files.pythonhosted.org/packages/5a/09/adce371f27ca039411da9659f7430fcc2ba6cd0c7b3e4467a0f091be7fa9/librt-0.11.0-cp314-cp314-win32.whl", hash = "sha256:d2277a05f6dcb9fd13db9566aac4fabd68c3ea1ea46ee5567d4eef8efa495a2f", size = 96965, upload-time = "2026-05-10T18:16:46.039Z" }, + { url = "https://files.pythonhosted.org/packages/d6/ee/8ac720d98548f173c7ce2e632a7ca94673f74cacd5c8162a84af5b35958a/librt-0.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:ab73e8db5e3f564d812c1f5c3a175930a5f9bc96ccb5e3b22a34d7858b401cf7", size = 115151, upload-time = "2026-05-10T18:16:47.133Z" }, + { url = "https://files.pythonhosted.org/packages/94/20/c900cf14efeb09b6bef2b2dff20779f73464b97fd58d1c6bccc379588ae3/librt-0.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:aea3caa317752e3a466fa8af45d91ee0ea8c7fdd96e42b0a8dd9b76a7931eba1", size = 98850, upload-time = "2026-05-10T18:16:48.597Z" }, + { url = "https://files.pythonhosted.org/packages/0c/71/944bfe4b64e12abffcd3c15e1cce07f72f3d55655083786285f4dedeb532/librt-0.11.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d1b36540d7aaf9b9101b3a6f376c8d8e9f7a9aec93ed05918f2c69d493ffef72", size = 151138, upload-time = "2026-05-10T18:16:49.839Z" }, + { url = "https://files.pythonhosted.org/packages/b6/10/99e64a5c86989357fda078c8143c533389585f6473b7439172dd8f3b3b2d/librt-0.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:efbb343ab2ce3540f4ecbe6315d677ed70f37cd9a72b1e58066c918ca83acbaa", size = 151976, upload-time = "2026-05-10T18:16:51.062Z" }, + { url = "https://files.pythonhosted.org/packages/21/31/5072ad880946d83e5ea4147d6d018c78eefce85b77819b19bdd0ee229435/librt-0.11.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa0dd688aab3f7914d3e6e5e3554978e0383312fb8e771d84be008a35b9ee548", size = 557927, upload-time = "2026-05-10T18:16:52.632Z" }, + { url = "https://files.pythonhosted.org/packages/5e/8d/70b5fb7cfbab60edbe7381614ab985da58e144fbf465c86d44c95f43cdca/librt-0.11.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f5fb36b8c6c63fdcbb1d526d94c0d1331610d43f4118cc1beb4efef4f3faacb2", size = 539698, upload-time = "2026-05-10T18:16:53.934Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a3/ba3495a0b3edbd24a4cae0d1d3c64f39a9fc45d06e812101289b50c1a619/librt-0.11.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4a9a237d13addb93715b6fee74023d5ee3469b53fce527626c0e088aa585805f", size = 577162, upload-time = "2026-05-10T18:16:55.589Z" }, + { url = "https://files.pythonhosted.org/packages/f7/db/36e25fb81f99937ff1b96612a1dc9fd66f039cb9cc3aee12c01fac31aab9/librt-0.11.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5ddd17bd87b2c56ddd60e546a7984a2e64c4e8eab92fb4cf3830a48ad5469d51", size = 566494, upload-time = "2026-05-10T18:16:56.975Z" }, + { url = "https://files.pythonhosted.org/packages/33/0d/3f622b47f0b013eeb9cf4cc07ae9bfe378d832a4eec998b2b209fe84244d/librt-0.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd43992b4473d42f12ff9e68326079f0696d9d4e6000e8f39a0238d482ba6ee2", size = 596858, upload-time = "2026-05-10T18:16:58.374Z" }, + { url = "https://files.pythonhosted.org/packages/a9/02/71b90bc93039c46a2000651f6ad60122b114c8f54c4ad306e0e96f5b75ad/librt-0.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:f8e3e8056dd674e279741485e2e512d6e9a751c7455809d0114e6ebf8d781085", size = 590318, upload-time = "2026-05-10T18:16:59.676Z" }, + { url = "https://files.pythonhosted.org/packages/04/04/418cb3f75621e2b761fb1ab0f017f4d70a1a72a6e7c74ee4f7e8d198c2f3/librt-0.11.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c1f708d8ae9c56cf38a903c44297243d2ec83fd82b396b977e0144a3e76217e3", size = 575115, upload-time = "2026-05-10T18:17:01.007Z" }, + { url = "https://files.pythonhosted.org/packages/cc/2c/5a2183ac58dd911f26b5d7e7d7d8f1d87fcecdddd99d6c12169a258ff62c/librt-0.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0add982e0e7b9fc14cf4b33789d5f13f66581889b88c2f58099f6ce8f92617bd", size = 617918, upload-time = "2026-05-10T18:17:02.682Z" }, + { url = "https://files.pythonhosted.org/packages/15/1f/dc6771a52592a4451be6effa200cbfc9cec61e4393d3033d81a9d307961d/librt-0.11.0-cp314-cp314t-win32.whl", hash = "sha256:2b481d846ac894c4e8403c5fd0e87c5d11d6499e404b474602508a224ff531c8", size = 103562, upload-time = "2026-05-10T18:17:03.99Z" }, + { url = "https://files.pythonhosted.org/packages/62/4a/7d1415567027286a75ba1093ec4aca11f073e0f559c530cf3e0a757ad55c/librt-0.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:28edb433edde181112a908c78907af28f964eabc15f4dd16c9d66c834302677c", size = 124327, upload-time = "2026-05-10T18:17:05.465Z" }, + { url = "https://files.pythonhosted.org/packages/ce/62/b40b382fa0c66fee1478073eb8db352a4a6beda4a1adccf1df911d8c289c/librt-0.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dee008f20b542e3cd162ba338a7f9ec0f6d23d395f66fe8aeeec3c9d067ea253", size = 102572, upload-time = "2026-05-10T18:17:06.809Z" }, ] [[package]] @@ -327,31 +255,6 @@ linkify = [ { name = "linkify-it-py" }, ] -[[package]] -name = "mcp" -version = "1.27.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "httpx" }, - { name = "httpx-sse" }, - { name = "jsonschema" }, - { name = "pydantic" }, - { name = "pydantic-settings" }, - { name = "pyjwt", extra = ["crypto"] }, - { name = "python-multipart" }, - { name = "pywin32", marker = "sys_platform == 'win32'" }, - { name = "sse-starlette" }, - { name = "starlette" }, - { name = "typing-extensions" }, - { name = "typing-inspection" }, - { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/38/83/d1efe7c2980d8a3afa476f4e3d42d53dd54c0ab94c27bee5d755b45c8b73/mcp-1.27.1.tar.gz", hash = "sha256:0f47e1820f8f8f941466b39749eb1d1839a04caddca2bc60e9d46e8a99914924", size = 608458, upload-time = "2026-05-08T16:50:12.601Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fd/73/42d9596facebdb533b7f0b86c1b0364ef350d1f8ba78b1052e8a58b48b65/mcp-1.27.1-py3-none-any.whl", hash = "sha256:1af3c4203b329430fde7a87b4fcb6392a041f5cb851fd68fc674016ab4e7c06f", size = 216260, upload-time = "2026-05-08T16:50:10.547Z" }, -] - [[package]] name = "mdit-py-plugins" version = "0.6.1" @@ -383,21 +286,74 @@ wheels = [ ] [[package]] -name = "platformdirs" -version = "4.9.6" +name = "mypy" +version = "2.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9f/4a/0883b8e3802965322523f0b200ecf33d31f10991d0401162f4b23c698b42/platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", size = 29400, upload-time = "2026-04-09T00:04:10.812Z" } +dependencies = [ + { name = "ast-serialize" }, + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/15/cca9d88503549ed6fedeaa1d448cdddd542ee8a490232d732e278036fbf2/mypy-2.1.0.tar.gz", hash = "sha256:81e76ad12c2d804512e9b13240d1588316531bfba07558286078bfbce9613633", size = 3898359, upload-time = "2026-05-11T18:37:36.237Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" }, + { url = "https://files.pythonhosted.org/packages/95/b1/55861beb5c339b44f9a2ba92df9e2cb1eeb4ae1eee674cdf7772c797778b/mypy-2.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:244358bf1c0da7722230bce60683d52e8e9fd030554926f15b747a84efb5b3af", size = 14874381, upload-time = "2026-05-11T18:37:31.784Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b3/b7f770114b7d0ac92d0f76e8d93c2780844a70488a90e91821927850da86/mypy-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4ec7c57657493c7a75534df2751c8ae2cda383c16ecc55d2106c54476b1b16f6", size = 13665501, upload-time = "2026-05-11T18:34:23.063Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f3/8ae2037967e2126689a0c11d99e2b707134a565191e92c60ca2572aec60a/mypy-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8161b6ff4392410023224f0969d17db93e1e154bc3e4ba62598e720723ae211", size = 14045750, upload-time = "2026-05-11T18:31:48.151Z" }, + { url = "https://files.pythonhosted.org/packages/a0/32/615eb5911859e43d054941b0d0a7d06cfa2870eba86529cf385b052b111c/mypy-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf03e12003084a67395184d3eb8cbd6a489dc3655b5664b28c210a9e2403ab0b", size = 15061630, upload-time = "2026-05-11T18:37:06.898Z" }, + { url = "https://files.pythonhosted.org/packages/d4/03/4eafbfff8bfab1b87082741eae6e6a624028c984e6708b73bce2a8570c9d/mypy-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:20509760fd791c51579d573153407d226385ec1f8bcce55d730b354f3336bc22", size = 15288831, upload-time = "2026-05-11T18:31:18.07Z" }, + { url = "https://files.pythonhosted.org/packages/99/ee/919661478e5891a3c96e549c036e467e64563ab85995b10c53c8358e16a3/mypy-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:6753d0c1fdd6b1a23b9e4f283ce80b2153b724adcb2653b20b85a8a28ac6436b", size = 11135228, upload-time = "2026-05-11T18:34:31.23Z" }, + { url = "https://files.pythonhosted.org/packages/24/0a/6a12b9782ca0831a553192f351679f4548abc9d19a7cc93bb7feb02084c7/mypy-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:98ebb6589bb3b6d0c6f0c459d53ca55b8091fbc13d277c4041c885392e8195e8", size = 10040684, upload-time = "2026-05-11T18:36:48.199Z" }, + { url = "https://files.pythonhosted.org/packages/6e/dd/c7191469c777f07689c032a8f7326e393ea34c92d6d76eb7ce5ba57ea66d/mypy-2.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:35aac3bb114e03888f535d5eb51b8bafbb3266586b599da1940f9b1be3ec5bd5", size = 14852174, upload-time = "2026-05-11T18:31:38.929Z" }, + { url = "https://files.pythonhosted.org/packages/55/8c/aed55408879043d72bb9135f4d0d19a02b886dd569631e113e3d2706cb8d/mypy-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8de55a8c861f2a49331f807be98d90caeceeef520bde13d43a160207f8af613e", size = 13651542, upload-time = "2026-05-11T18:36:04.636Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8e/f371a824b1f1fa8ea6e3dbb8703d232977d572be2329554a3bc4d960302f/mypy-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5fdf2941a07434af755837d9880f7d7d25f1dacb1af9dcd4b9b66f2220a3024e", size = 14033929, upload-time = "2026-05-11T18:35:55.742Z" }, + { url = "https://files.pythonhosted.org/packages/94/21/f54be870d6dd53a82c674407e0f8eed7174b05ec78d42e5abd7b42e84fd5/mypy-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e195b817c13f02352a9c124301f9f30f078405444679b6753c1b96b6eed37285", size = 15039200, upload-time = "2026-05-11T18:33:10.281Z" }, + { url = "https://files.pythonhosted.org/packages/17/99/bf21748626a40ce59fd29a39386ab46afec88b7bd2f0fa6c3a97c995523f/mypy-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5431d42af987ebd92ba2f71d45c85ed41d8e6ca9f5fd209a69f68f707d2469e5", size = 15272690, upload-time = "2026-05-11T18:32:07.205Z" }, + { url = "https://files.pythonhosted.org/packages/d6/d7/9e90d2cf47100bea550ed2bc7b0d4de3a62181d84d5e37da0003e8462637/mypy-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:767fe8c66dc3e01e19e1737d4c38ebefead16125e1b8e58ad421903b376f5c65", size = 11147435, upload-time = "2026-05-11T18:33:56.477Z" }, + { url = "https://files.pythonhosted.org/packages/ec/46/e5c449e858798e35ffc90946282a27c62a77be743fe17480e4977374eb91/mypy-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:ecfe70d43775ab99562ab128ce49854a362044c9f894961f68f898c23cb7429d", size = 10035052, upload-time = "2026-05-11T18:32:30.049Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ca/b279a672e874aedd5498ae25f722dacc8aa86bbffb939b3f97cbb1cf6686/mypy-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7354c5a7f69d9345c3d6e69921d57088eea3ddeeb6b20d34c1b3855b02c36ec2", size = 14848422, upload-time = "2026-05-11T18:35:45.984Z" }, + { url = "https://files.pythonhosted.org/packages/27/e6/3efe56c631d959b9b4454e208b0ac4b7f4f58b404c89f8bec7b49efdfc21/mypy-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:49890d4f76ac9e06ec117f9e09f3174da70a620a0c300953d8595c926e80947f", size = 13677374, upload-time = "2026-05-11T18:36:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/84/7f/8107ea87a44fd1f1b59882442f033c9c3488c127201b1d1d15f1cbd6022e/mypy-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:761be68e023ef5d94678772396a8af1220030f80837a3afd8d0aef3b419666f4", size = 14055743, upload-time = "2026-05-11T18:35:18.361Z" }, + { url = "https://files.pythonhosted.org/packages/51/4d/b6d34db183133b83761b9199a82d31557cdbb70a380d8c3b3438e11882a3/mypy-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c90345fc182dc363b891350457ec69c35140858538f38b4540845afcc32b1aef", size = 15020937, upload-time = "2026-05-11T18:34:59.618Z" }, + { url = "https://files.pythonhosted.org/packages/ff/d7/f08360c691d758acb02f45022c34d98b92892f4ea756644e1000d4b9f3d8/mypy-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b84802e7b5a6daf1f5e15bc9fcd7ddae77be13981ffab037f1c67bb84d67d135", size = 15253371, upload-time = "2026-05-11T18:36:41.081Z" }, + { url = "https://files.pythonhosted.org/packages/67/1b/09460a13719530a19bce27bd3bc8449e83569dd2ba7faf51c9c3c30c0b61/mypy-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:022c771234936ceac541ebaf836fe9e2abeb3f5e09aff21588fe543ff006fe21", size = 11326429, upload-time = "2026-05-11T18:34:13.526Z" }, + { url = "https://files.pythonhosted.org/packages/40/62/75dbf0f82f7b6680340efc614af29dd0b3c17b8a4f1cd09b8bd2fd6bc814/mypy-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:498207db725cec88829a6a5c2fc771205fd043719ef98bc49aba8fb9fc4e6d57", size = 10218799, upload-time = "2026-05-11T18:32:23.491Z" }, + { url = "https://files.pythonhosted.org/packages/b2/66/caca04ed7d972fb6eb6dd1ccd6df1de5c38fae8c5b3dc1c4e8e0d85ee6b9/mypy-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7d5e5cad0efeba72b93cd17490cc0d69c5ac9ca132994fe3fb0314808aeeb83e", size = 15923458, upload-time = "2026-05-11T18:35:28.64Z" }, + { url = "https://files.pythonhosted.org/packages/ed/52/2d90cbe49d014b13ed7ff337930c30bad35893fe38a1e4641e756bb62191/mypy-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ff715050c127d724fd260a2e666e7747fdd83511c0c47d449d98238970aef780", size = 14757697, upload-time = "2026-05-11T18:36:14.208Z" }, + { url = "https://files.pythonhosted.org/packages/ac/37/d98f4a14e081b238992d0ed96b6d39c7cc0148c9699eb71eaa68629665ea/mypy-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:82208da9e09414d520e912d3e462d454854bed0810b71540bb016dcbca7308fd", size = 15405638, upload-time = "2026-05-11T18:33:48.249Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c2/15c46613b24a84fad2aea1248bf9619b99c2767ae9071fe224c179a0b7d4/mypy-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e79ebc1b904b84f0310dff7469655a9c36c7a68bddb37bdd42b67a332df61d08", size = 16215852, upload-time = "2026-05-11T18:32:50.296Z" }, + { url = "https://files.pythonhosted.org/packages/5c/90/9c16a57f482c76d25f6379762b56bbf65c711d8158cf271fb2802cfb0640/mypy-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e583edc957cfb0deb142079162ae826f58449b116c1d442f2d91c69d9fced081", size = 16452695, upload-time = "2026-05-11T18:33:38.182Z" }, + { url = "https://files.pythonhosted.org/packages/0f/4c/215a4eeb63cacc5f17f516691ea7285d11e249802b942476bff15922a314/mypy-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b33b6cd332695bba180d55e717a79d3038e479a2c49cc5eb3d53603409b9a5d7", size = 12866622, upload-time = "2026-05-11T18:34:39.945Z" }, + { url = "https://files.pythonhosted.org/packages/4b/50/1043e1db5f455ffe4c9ab22747cd8ca2bc492b1e4f4e21b130a44ee2b217/mypy-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:4f910fe825376a7b66ef7ca8c98e5a149e8cd64c19ae71d84047a74ee060d4e6", size = 10610798, upload-time = "2026-05-11T18:36:31.444Z" }, + { url = "https://files.pythonhosted.org/packages/0d/2a/13ca1f292f6db1b98ff495ef3467736b331621c5917cad984b7043e7348d/mypy-2.1.0-py3-none-any.whl", hash = "sha256:a663814603a5c563fb87a4f96fb473eeb30d1f5a4885afcf44f9db000a366289", size = 2693302, upload-time = "2026-05-11T18:31:29.246Z" }, ] [[package]] -name = "pycparser" -version = "3.0" +name = "mypy-extensions" +version = "1.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.9.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9f/4a/0883b8e3802965322523f0b200ecf33d31f10991d0401162f4b23c698b42/platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", size = 29400, upload-time = "2026-04-09T00:04:10.812Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" }, ] [[package]] @@ -490,20 +446,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, ] -[[package]] -name = "pydantic-settings" -version = "2.14.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pydantic" }, - { name = "python-dotenv" }, - { name = "typing-inspection" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/07/60/1d1e59c9c90d54591469ada7d268251f71c24bdb765f1a8a832cee8c6653/pydantic_settings-2.14.1.tar.gz", hash = "sha256:e874d3bec7e787b0c9958277956ed9b4dd5de6a80e162188fdaff7c5e26fd5fa", size = 235551, upload-time = "2026-05-08T13:40:06.542Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ae/8d/f1af3832f5e6eb13ba94ee809e72b8ecb5eef226d27ee0bef7d963d943c7/pydantic_settings-2.14.1-py3-none-any.whl", hash = "sha256:6e3c7edfd8277687cdc598f56e5cff0e9bfff0910a3749deaa8d4401c3a2b9de", size = 60964, upload-time = "2026-05-08T13:40:04.958Z" }, -] - [[package]] name = "pygments" version = "2.20.0" @@ -513,68 +455,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] -[[package]] -name = "pyjwt" -version = "2.12.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564, upload-time = "2026-03-13T19:27:37.25Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" }, -] - -[package.optional-dependencies] -crypto = [ - { name = "cryptography" }, -] - -[[package]] -name = "python-dotenv" -version = "1.2.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, -] - -[[package]] -name = "python-multipart" -version = "0.0.28" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/54/a85eb421fbdd5007bc5af39d0f4ed9fa609e0fedbfdc2adcf0b34526870e/python_multipart-0.0.28.tar.gz", hash = "sha256:8550da197eac0f7ab748961fc9509b999fa2662ea25cef857f05249f6893c0f8", size = 45314, upload-time = "2026-05-10T11:05:16.596Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f3/a2/43bbc5860b5034e2af4ef99a0e04d726ff329c43e192ef3abaa8d7ecfce5/python_multipart-0.0.28-py3-none-any.whl", hash = "sha256:10faac07eb966c3f48dc415f9dee46c04cb10d58d30a35677db8027c825ed9b6", size = 29438, upload-time = "2026-05-10T11:05:15.052Z" }, -] - -[[package]] -name = "pywin32" -version = "311" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31", size = 8706543, upload-time = "2025-07-14T20:13:20.765Z" }, - { url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040, upload-time = "2025-07-14T20:13:22.543Z" }, - { url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102, upload-time = "2025-07-14T20:13:24.682Z" }, - { url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" }, - { url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" }, - { url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" }, - { url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" }, - { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" }, - { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" }, -] - -[[package]] -name = "referencing" -version = "0.37.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "attrs" }, - { name = "rpds-py" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, -] - [[package]] name = "rich" version = "15.0.0" @@ -588,87 +468,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, ] -[[package]] -name = "rpds-py" -version = "0.30.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" }, - { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" }, - { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" }, - { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" }, - { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" }, - { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" }, - { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" }, - { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" }, - { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" }, - { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" }, - { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" }, - { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" }, - { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" }, - { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" }, - { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" }, - { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" }, - { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" }, - { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" }, - { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" }, - { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" }, - { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" }, - { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" }, - { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" }, - { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" }, - { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" }, - { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" }, - { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" }, - { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" }, - { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" }, - { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" }, - { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" }, - { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" }, - { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" }, - { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" }, - { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" }, - { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" }, - { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" }, - { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" }, - { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" }, - { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" }, - { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" }, - { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" }, - { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" }, - { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" }, - { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" }, - { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" }, - { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" }, - { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" }, - { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" }, - { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" }, - { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" }, - { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" }, - { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" }, - { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" }, - { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" }, - { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" }, - { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" }, - { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" }, - { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" }, - { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" }, - { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" }, - { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" }, - { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" }, - { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" }, - { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" }, - { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" }, - { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" }, - { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" }, - { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" }, - { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" }, - { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" }, - { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" }, - { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" }, -] - [[package]] name = "ruff" version = "0.15.13" @@ -694,32 +493,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9b/36/9c015cd052fca743dae8cb2aeb16b551444787467db42ceab0fc968865af/ruff-0.15.13-py3-none-win_arm64.whl", hash = "sha256:2471da9bd1068c8c064b5fd9c0c4b6dddffd6369cb1cd68b29993b1709ff1b21", size = 11179336, upload-time = "2026-05-14T13:44:33.026Z" }, ] -[[package]] -name = "sse-starlette" -version = "3.4.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "starlette" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f7/2b/58abc2d1fd397e7dde08e947e05c884d8ef2f78d5e2588c17a12d42d6994/sse_starlette-3.4.4.tar.gz", hash = "sha256:07e0fa0460138baf25cdd5fb28683472c3995dc1642225191b3832d62526bcb0", size = 31819, upload-time = "2026-05-12T17:37:17.019Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/67/805710444ea8cc75fbf70b920ed431a560c4bf9c57f7d5a3117213189399/sse_starlette-3.4.4-py3-none-any.whl", hash = "sha256:3f4dd50d8aed2771a091f3a83000323fc3844541c16b4fe585ae2420cc6df973", size = 16514, upload-time = "2026-05-12T17:37:15.601Z" }, -] - -[[package]] -name = "starlette" -version = "1.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" }, -] - [[package]] name = "tau-agent" version = "0.1.0" @@ -731,6 +504,7 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "mypy" }, { name = "ruff" }, ] @@ -741,7 +515,10 @@ requires-dist = [ ] [package.metadata.requires-dev] -dev = [{ name = "ruff", specifier = ">=0.15.12" }] +dev = [ + { name = "mypy", specifier = "~=2.1.0" }, + { name = "ruff", specifier = ">=0.15.12" }, +] [[package]] name = "textual" @@ -789,16 +566,3 @@ sdist = { url = "https://files.pythonhosted.org/packages/78/67/9a363818028526e2d wheels = [ { url = "https://files.pythonhosted.org/packages/61/73/d21edf5b204d1467e06500080a50f79d49ef2b997c79123a536d4a17d97c/uc_micro_py-2.0.0-py3-none-any.whl", hash = "sha256:3603a3859af53e5a39bc7677713c78ea6589ff188d70f4fee165db88e22b242c", size = 6383, upload-time = "2026-03-01T06:31:26.257Z" }, ] - -[[package]] -name = "uvicorn" -version = "0.47.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "h11" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f6/b1/8e7077a8641086aea449e1b5752a570f1b5906c64e0a33cd6d93b63a066b/uvicorn-0.47.0.tar.gz", hash = "sha256:7c9a0ea1a9414106bbab7324609c162d8fa0cdcdcb703060987269d77c7bb533", size = 90582, upload-time = "2026-05-14T18:16:54.455Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/15/41/ac2dfdbc1f60c7af4f994c7a335cfa7040c01642b605d65f611cecc2a1e4/uvicorn-0.47.0-py3-none-any.whl", hash = "sha256:2c5715bc12d1892d84752049f400cd1c3cb018514967fdfeb97640443a6a9432", size = 71301, upload-time = "2026-05-14T18:16:51.762Z" }, -] From ea9289a349abdc24ee57d9e8b2af339d48d6c9c6 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Fri, 22 May 2026 11:36:47 -0700 Subject: [PATCH 60/73] [tau] show tool results in a shaded block Add a tool-result CSS class with ansi_bright_black background to visually distinguish tool output from tool call lines and assistant text. Co-authored-by: anthropic/claude-opus-4.6, via tau --- examples/tau-agent/tau/app.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/examples/tau-agent/tau/app.py b/examples/tau-agent/tau/app.py index d9ca3a07..0990448b 100644 --- a/examples/tau-agent/tau/app.py +++ b/examples/tau-agent/tau/app.py @@ -560,6 +560,12 @@ class Bubble(textual.widgets.Static): Bubble.tool { color: $text-muted; } + Bubble.tool-result { + color: $text-muted; + background: ansi_bright_black; + margin: 0 0 1 0; + padding: 0 1; + } Bubble.thinking { color: $text-muted; text-style: dim italic; @@ -928,14 +934,14 @@ def append_tool_result(self, tool_call_id: str, chunk: str) -> None: """Append a streaming chunk to a tool-result bubble.""" bubble = self._tool_result_bubbles.get(tool_call_id) if bubble is None: - bubble = self.transcript.add_bubble("tool") + bubble = self.transcript.add_bubble("tool-result") self._tool_result_bubbles[tool_call_id] = bubble bubble.append(chunk) def show_tool_result(self, result: Any, is_error: bool) -> None: """Show the (possibly truncated) result of a tool call.""" self.transcript.add_bubble( - "tool", _format_tool_result(result, is_error) + "tool-result", _format_tool_result(result, is_error) ) def show_system(self, text: str) -> None: From 343d297c77320e428705e28f333f6f8353b2a176 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Fri, 22 May 2026 18:20:03 -0700 Subject: [PATCH 61/73] [tau] render edit tool calls as pi-style diffs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Show edit tool calls as a colored unified diff instead of the raw → edit(path=..., edits=[...]) one-liner. Uses difflib.SequenceMatcher on lines to collapse unchanged regions and show only actual changes with context. - Extract edit_string() from the edit tool so the diff renderer can apply edits to the pre-edit file content in memory. - Diff rendering happens at ToolEnd time (before execution) using the file's current content. - On session restore, falls back to the plain one-liner since the file may have changed. Proper restore support will require persisting the old content in the tool result (see TODO below). - Bubble/Transcript gain a renderable kwarg for Rich renderables. - Tool result bubbles use a darker background (#262626). - Add mypy to dev dependencies. HACK: To support diff rendering on session restore and over the wire, we are using an Aggregator to report it out, because only that supports things. Co-authored-by: anthropic/claude-opus-4.6, via tau --- examples/tau-agent/tau/app.py | 206 ++++++++++++++++++++++++++++++-- examples/tau-agent/tau/tools.py | 107 +++++++++++++---- 2 files changed, 275 insertions(+), 38 deletions(-) diff --git a/examples/tau-agent/tau/app.py b/examples/tau-agent/tau/app.py index 0990448b..9fef994b 100644 --- a/examples/tau-agent/tau/app.py +++ b/examples/tau-agent/tau/app.py @@ -26,6 +26,8 @@ import ai import ai.types.usage +import pydantic +import rich.console import rich.markdown import rich.text import textual @@ -237,7 +239,20 @@ def _replay_session(app: TauApp) -> None: elif msg.role == "tool": for part in msg.parts: if isinstance(part, ai.messages.ToolResultPart): - app.show_tool_result(part.result, part.is_error) + diff = ( + _format_edit_diff_from_result(part.result) + if part.tool_name == "edit" + else None + ) + if diff is not None: + app.transcript.add_bubble( + "tool-result", + renderable=diff, + ) + result = part.result + if part.tool_name == "edit" and isinstance(result, dict): + result = result.get("message", result) + app.show_tool_result(result, part.is_error) app.show_system( f"resumed session {app.session.session_id} " f"({len(app.session.messages) - 1} messages) — model: {MODEL_ID}", @@ -279,9 +294,16 @@ async def _run_turn(app: TauApp) -> None: app.append_text(event.chunk) elif isinstance(event, ai.events.ToolEnd): tc = event.tool_call - app.show_tool_call(tc.tool_name, tc.tool_args) + app.show_tool_call( + tc.tool_name, + tc.tool_args, + event.tool_call_id, + ) elif isinstance(event, ai.events.PartialToolCallResult): - if event.tool_call_id is not None: + if ( + event.tool_call_id is not None + and event.tool_name != "edit" + ): app.append_tool_result( event.tool_call_id, str(event.value) ) @@ -289,7 +311,10 @@ async def _run_turn(app: TauApp) -> None: for part in event.results: # Skip if we already streamed this result. if part.tool_call_id not in app._tool_result_bubbles: - app.show_tool_result(part.result, part.is_error) + result = part.result + if isinstance(result, tools.EditResult): + result = result.message + app.show_tool_result(result, part.is_error) # -- provider-executed (builtin) tools -- elif isinstance(event, ai.events.BuiltinToolEnd): btc = event.tool_call @@ -372,6 +397,126 @@ def _format_tool_result(result: Any, is_error: bool) -> str: return f"{marker}\n {indented}" +_DIFF_CTX = 2 # context lines above/below each changed line +_DIFF_ADD = "color(108)" # green foreground +_DIFF_DEL = "color(168)" # red/pink foreground + + +def _render_diff( + filepath: str, + old_content: str, + new_content: str, +) -> rich.text.Text | None: + """Render a pi-style diff between old and new file content. + + Uses ``difflib.SequenceMatcher`` on lines to collapse unchanged + regions. Returns *None* if the contents are identical. + """ + import difflib + + old_lines = old_content.splitlines() + new_lines = new_content.splitlines() + gutter_w = len(str(max(len(old_lines), len(new_lines)) + 1)) + 1 + + sm = difflib.SequenceMatcher( + None, + old_lines, + new_lines, + autojunk=False, + ) + + out = rich.text.Text() + out.append("edit ", style="bold") + out.append(f"{filepath}\n\n") + + opcodes = sm.get_opcodes() + for oi, (op, i1, i2, j1, j2) in enumerate(opcodes): + if op == "equal": + head_end = i2 + if oi > 0: + head_end = min(i1 + _DIFF_CTX, i2) + for k in range(i1, head_end): + _diff_ctx(out, gutter_w, k + 1, old_lines[k]) + if oi < len(opcodes) - 1: + tail_start = max(i2 - _DIFF_CTX, i1) + tail_start = max(tail_start, head_end) + if tail_start > head_end: + out.append(" ...\n", style="dim") + for k in range(tail_start, i2): + _diff_ctx(out, gutter_w, k + 1, old_lines[k]) + else: + if op in ("replace", "delete"): + for k in range(i1, i2): + _diff_line(out, gutter_w, "-", k + 1, old_lines[k]) + if op in ("replace", "insert"): + for k in range(j1, j2): + _diff_line(out, gutter_w, "+", k + 1, new_lines[k]) + + if out.plain.endswith("\n"): + out.right_crop(1) + return out or None + + +def _format_edit_diff_from_args( + filepath: str, + edits: list[dict[str, str]], +) -> rich.text.Text | None: + """Render an edit diff at ToolEnd time by reading the file from disk.""" + try: + old_content = pathlib.Path(filepath).read_text(encoding="utf-8") + except OSError: + return None + try: + text_edits = [tools.TextEdit.model_validate(e) for e in edits] + except pydantic.ValidationError: + return None + try: + new_content = tools.edit_string(old_content, text_edits, filepath) + except (ValueError, KeyError): + return None + return _render_diff(filepath, old_content, new_content) + + +def _format_edit_diff_from_result( + result: Any, +) -> rich.text.Text | None: + """Render an edit diff from a persisted EditResult.""" + if not isinstance(result, dict): + return None + try: + er = tools.EditResult.model_validate(result) + except (pydantic.ValidationError, Exception): + return None + # Infer filepath from the message. + msg = er.message + prefix = " in " + idx = msg.rfind(prefix) + if idx < 0: + return None + filepath = msg[idx + len(prefix) :].rstrip(".") + return _render_diff(filepath, er.old_content, er.new_content) + + +def _diff_ctx( + out: rich.text.Text, + gw: int, + ln: int, + text: str, +) -> None: + out.append(f" {ln:>{gw}} {text}\n", style="dim") + + +def _diff_line( + out: rich.text.Text, + gw: int, + prefix: str, + ln: int, + text: str, +) -> None: + style = _DIFF_ADD if prefix == "+" else _DIFF_DEL + out.append(f"{prefix} {ln:>{gw}} {text}\n", style=style) + + # --------------------------------------------------------------------------- # Hook — thin wrapper so UI code never touches ai.messages.HookPart # --------------------------------------------------------------------------- @@ -562,7 +707,7 @@ class Bubble(textual.widgets.Static): } Bubble.tool-result { color: $text-muted; - background: ansi_bright_black; + background: #262626; margin: 0 0 1 0; padding: 0 1; } @@ -572,12 +717,21 @@ class Bubble(textual.widgets.Static): } """ - def __init__(self, role: str, initial: str = "") -> None: + def __init__( + self, + role: str, + initial: str = "", + *, + renderable: rich.console.RenderableType | None = None, + ) -> None: super().__init__() self.add_class(role) self._role = role self._raw = "" - if initial: + self._renderable = renderable + if renderable is not None: + self.update(renderable) + elif initial: self.append(initial) else: self._redraw() @@ -604,8 +758,14 @@ class Transcript(textual.containers.VerticalScroll): } """ - def add_bubble(self, role: str, text: str = "") -> Bubble: - bubble = Bubble(role, text) + def add_bubble( + self, + role: str, + text: str = "", + *, + renderable: rich.console.RenderableType | None = None, + ) -> Bubble: + bubble = Bubble(role, text, renderable=renderable) self.mount(bubble) return bubble @@ -904,7 +1064,7 @@ def transcript(self) -> Transcript: # lazily create bubbles as needed. _text_bubble: Bubble | None = None _thinking_bubble: Bubble | None = None - _tool_result_bubbles: dict[str, Bubble] + _tool_result_bubbles: dict[str, Bubble] = {} def _reset_turn_bubbles(self) -> None: self._text_bubble = None @@ -923,13 +1083,35 @@ def append_text(self, chunk: str) -> None: self._text_bubble = self.transcript.add_bubble("assistant") self._text_bubble.append(chunk) - def show_tool_call(self, name: str, args: str) -> None: + def show_tool_call( + self, name: str, args: str, tool_call_id: str = "" + ) -> None: """Show a completed tool invocation line.""" - self.transcript.add_bubble("tool", _format_tool_call(name, args)) + rendered = False + if name == "edit": + rendered = self._show_edit_diff(args) + if not rendered: + self.transcript.add_bubble("tool", _format_tool_call(name, args)) # Next text from the model should start a fresh bubble so # tool output and prose stay visually separated. self._text_bubble = None + def _show_edit_diff(self, args: str) -> bool: + """Try to render an edit call as a diff. Returns True on success.""" + try: + parsed = json.loads(args) if args else {} + except (json.JSONDecodeError, AttributeError): + return False + filepath = parsed.get("path", "") + edits = parsed.get("edits", []) + if not filepath or not edits: + return False + diff = _format_edit_diff_from_args(filepath, edits) + if diff is None: + return False + self.transcript.add_bubble("tool-result", renderable=diff) + return True + def append_tool_result(self, tool_call_id: str, chunk: str) -> None: """Append a streaming chunk to a tool-result bubble.""" bubble = self._tool_result_bubbles.get(tool_call_id) diff --git a/examples/tau-agent/tau/tools.py b/examples/tau-agent/tau/tools.py index 69c7319c..c5669774 100644 --- a/examples/tau-agent/tau/tools.py +++ b/examples/tau-agent/tau/tools.py @@ -19,9 +19,12 @@ import dataclasses import pathlib import re -from typing import Any, Literal +from collections.abc import AsyncGenerator +from typing import Annotated, Any, Literal import ai +import ai.agents +import ai.types.events import pydantic # Image formats we support (subset that models typically accept). @@ -344,39 +347,27 @@ class TextEdit(pydantic.BaseModel): ) -@ai.tool(require_approval=True) -async def edit(path: str, edits: list[TextEdit]) -> str: - """Edit a single file using exact text replacement. +def edit_string( + content: str, + edits: list[TextEdit], + filename: str = "", +) -> str: + """Apply edits to a string and return the result. - Every edits[].oldText must match a unique, non-overlapping region of - the original file. Each oldText is matched against the ORIGINAL - file, not after earlier edits are applied; emit one call with - multiple disjoint edits rather than several calls. + Each edit's ``oldText`` must match exactly once in *content*. + Edits are resolved against the original content and must not overlap. """ - p = pathlib.Path(path).expanduser() - if not p.exists(): - raise FileNotFoundError(f"No such file: {path}") - if not p.is_file(): - raise IsADirectoryError(f"Not a file: {path}") - if not edits: - raise ValueError("edits must be non-empty") - - content = p.read_text(encoding="utf-8") - - # Resolve each edit to a (start, end) span in the original content - # and check uniqueness up front. Apply right-to-left so spans don't - # shift under us. spans: list[tuple[int, int, str, int]] = [] # start, end, new, idx for i, e in enumerate(edits): if not e.oldText: raise ValueError(f"edits[{i}].oldText is empty") count = content.count(e.oldText) if count == 0: - raise ValueError(f"edits[{i}].oldText not found in {path}") + raise ValueError(f"edits[{i}].oldText not found in {filename}") if count > 1: raise ValueError( f"edits[{i}].oldText matches {count} times " - f"in {path}; must be unique" + f"in {filename}; must be unique" ) pos = content.index(e.oldText) spans.append((pos, pos + len(e.oldText), e.newText, i)) @@ -388,12 +379,76 @@ async def edit(path: str, edits: list[TextEdit]) -> str: f"edits[{spans[j - 1][3]}] and edits[{spans[j][3]}] overlap" ) - new_content = content + result = content for start, end, new_text, _ in reversed(spans): - new_content = new_content[:start] + new_text + new_content[end:] + result = result[:start] + new_text + result[end:] + return result + + +class EditResult(pydantic.BaseModel): + """Structured result from the edit tool. + + Persisted in the session so the UI can render a diff on restore. + The model only sees ``message`` via the aggregator's + ``to_model_input``. + """ + + message: str + old_content: str + new_content: str + + +class _EditAggregator( + ai.types.events.Aggregator[EditResult, EditResult, str], +): + def __init__(self) -> None: + self._result: EditResult | None = None + def feed(self, item: EditResult) -> None: + self._result = item + + def snapshot(self) -> EditResult: + assert self._result is not None + return self._result + + @classmethod + def to_model_input(cls, snapshot: EditResult) -> str: + if isinstance(snapshot, dict): + return snapshot.get("message", str(snapshot)) + return snapshot.message + + +type _EditTool = Annotated[ + AsyncGenerator[EditResult], + ai.agents.Aggregate(_EditAggregator), +] + + +@ai.tool(require_approval=True) +async def edit(path: str, edits: list[TextEdit]) -> _EditTool: + """Edit a single file using exact text replacement. + + Every edits[].oldText must match a unique, non-overlapping region of + the original file. Each oldText is matched against the ORIGINAL + file, not after earlier edits are applied; emit one call with + multiple disjoint edits rather than several calls. + """ + p = pathlib.Path(path).expanduser() + if not p.exists(): + raise FileNotFoundError(f"No such file: {path}") + if not p.is_file(): + raise IsADirectoryError(f"Not a file: {path}") + if not edits: + raise ValueError("edits must be non-empty") + + old_content = p.read_text(encoding="utf-8") + new_content = edit_string(old_content, edits, path) p.write_text(new_content, encoding="utf-8") - return f"Successfully replaced {len(edits)} block(s) in {path}." + yield EditResult( + message=f"Successfully replaced {len(edits)} block(s) in {path}.", + old_content=old_content, + new_content=new_content, + ) # --------------------------------------------------------------------------- From 863b19ae23bb2cdaa8efcd5387142baf7c1f2538 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Fri, 22 May 2026 18:51:33 -0700 Subject: [PATCH 62/73] [tau] fix: reset bubble state at start of each chat turn Calls _reset_turn_bubbles() before processing each queued message so that a new turn's response doesn't append into the previous turn's assistant bubble. Co-authored-by: anthropic/claude-opus-4.6, via tau --- examples/tau-agent/tau/app.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/examples/tau-agent/tau/app.py b/examples/tau-agent/tau/app.py index 9fef994b..dc527405 100644 --- a/examples/tau-agent/tau/app.py +++ b/examples/tau-agent/tau/app.py @@ -267,6 +267,10 @@ async def chat_loop(app: TauApp) -> None: the ``ai`` library lives here. """ while app.pending: + # Reset bubble state so each turn gets fresh bubbles — otherwise + # a queued message's response appends into the previous turn's + # assistant bubble. + app._reset_turn_bubbles() # Pop one queued message into history per turn so the model sees # a clean user → assistant → user → … sequence. app.session.messages.append(ai.user_message(app.pending.pop(0))) From 66e2da5a2b07789d524d52fcaa3ff68d9c9068f8 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Fri, 22 May 2026 18:58:27 -0700 Subject: [PATCH 63/73] [tau] add AGENTS.md for coding agents Documents project layout, how to run linting/type-checking with uv, the .tau/TODO task list, and project conventions. Co-authored-by: anthropic/claude-opus-4.6, via tau --- examples/tau-agent/AGENTS.md | 49 ++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 examples/tau-agent/AGENTS.md diff --git a/examples/tau-agent/AGENTS.md b/examples/tau-agent/AGENTS.md new file mode 100644 index 00000000..f59054d1 --- /dev/null +++ b/examples/tau-agent/AGENTS.md @@ -0,0 +1,49 @@ +# AGENTS.md — tau-agent + +## Overview + +`tau` is a single-process coding-agent TUI built on the `ai` library and +Textual. It gives the model seven filesystem/shell tools (read, write, +edit, bash, grep, find, ls) with an approval gate for mutating +operations. + +## Project layout + +``` +tau/ + app.py — Textual app, chat loop, approval flow + tools.py — tool definitions (mirrors pi's seven built-ins) + session.py — JSONL session persistence and resume +pyproject.toml — project metadata, dependencies, ruff/mypy config +``` + +## TODO list + +There may be a task list in `.tau/TODO` — check it for current +priorities and open items. + +## Running + +```bash +uv sync # install deps +uv run tau # launch the TUI +``` + +## Linting & type-checking + +```bash +uv run ruff check . # lint +uv run ruff format --check # format check +uv run mypy tau # type-check +``` + +## Conventions + +- Python ≥ 3.12. +- Line length: 80 (`ruff` and project style). +- Lint rule set: E, F, I, UP, B, SIM (see `pyproject.toml`). +- No workspace jail — the approval gate is the safety mechanism. +- Approval-gated tools (`write`, `edit`, `bash`) require operator + confirmation; reads are auto-approved. +- Sessions persist as JSONL under `.tau/sessions/`. +- Commit messages for this subdirectory should be prefixed with `[tau]`. From 4852363aa960e79af4a876704bec6e1085929766 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Fri, 22 May 2026 19:02:50 -0700 Subject: [PATCH 64/73] [tau] fix: handle legacy str results in _EditAggregator.to_model_input Old sessions store edit tool results as plain strings rather than EditResult dicts. Add an isinstance(str) guard so resuming those sessions doesn't crash with 'str has no attribute message'. Co-authored-by: anthropic/claude-opus-4.6, via tau --- examples/tau-agent/tau/tools.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/examples/tau-agent/tau/tools.py b/examples/tau-agent/tau/tools.py index c5669774..7972e2b4 100644 --- a/examples/tau-agent/tau/tools.py +++ b/examples/tau-agent/tau/tools.py @@ -413,6 +413,10 @@ def snapshot(self) -> EditResult: @classmethod def to_model_input(cls, snapshot: EditResult) -> str: + # COMPAT: Check if it is a str because old sessions stored it + # that way. Probably remove this. + if isinstance(snapshot, str): + return snapshot if isinstance(snapshot, dict): return snapshot.get("message", str(snapshot)) return snapshot.message From 5ca9f8ff7556e2dfb0db67d227d23c63ffc5d9c6 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Thu, 28 May 2026 11:36:35 -0700 Subject: [PATCH 65/73] [tau] default to claude-opus-4.8 Co-authored-by: anthropic/claude-opus-4.6, via tau --- examples/tau-agent/tau/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/tau-agent/tau/app.py b/examples/tau-agent/tau/app.py index dc527405..cae65ee8 100644 --- a/examples/tau-agent/tau/app.py +++ b/examples/tau-agent/tau/app.py @@ -41,7 +41,7 @@ from tau import session, tools -_raw_model = os.environ.get("TAU_MODEL", "gateway:anthropic/claude-opus-4.6") +_raw_model = os.environ.get("TAU_MODEL", "gateway:anthropic/claude-opus-4.8") MODEL_ID = _raw_model if ":" in _raw_model else f"gateway:{_raw_model}" # Only send gateway-specific options when routing through the gateway. From 4a0aa369d97cd235525a241692a9a422995a65da Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Thu, 28 May 2026 12:10:23 -0700 Subject: [PATCH 66/73] [tau] Only route to anthropic when using anthropic models The other providers don't support the code execution tool that we are using! --- examples/tau-agent/tau/app.py | 30 ++++++++++++++++++++++-------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/examples/tau-agent/tau/app.py b/examples/tau-agent/tau/app.py index cae65ee8..243d7165 100644 --- a/examples/tau-agent/tau/app.py +++ b/examples/tau-agent/tau/app.py @@ -44,11 +44,30 @@ _raw_model = os.environ.get("TAU_MODEL", "gateway:anthropic/claude-opus-4.8") MODEL_ID = _raw_model if ":" in _raw_model else f"gateway:{_raw_model}" -# Only send gateway-specific options when routing through the gateway. + +def _provider_slug(model_id: str) -> str: + """Extract the backend provider slug from a model id. + + "gateway:anthropic/claude-opus-4.8" -> "anthropic", + "anthropic:claude-..." -> "anthropic", + "openai/gpt-..." -> "openai". + """ + mid = model_id.lower() + if mid.startswith("gateway:"): + mid = mid[len("gateway:") :] + return mid.split("/")[0].split(":")[0] + + +# Only send gateway-specific options when routing through the gateway. Pin the +# gateway to the model's own provider via ``only`` so it never falls back to +# another backend that also serves the model (e.g. Bedrock/Vertex for Claude). STREAM_PARAMS: dict[str, Any] | None = ( { "providerOptions": { - "gateway": {"caching": "auto"}, + "gateway": { + "caching": "auto", + "only": [_provider_slug(MODEL_ID)], + }, "anthropic": { "thinking": {"type": "enabled", "budget_tokens": 10000} }, @@ -66,12 +85,7 @@ def _provider_tools(model_id: str) -> list[Any]: The gateway passes through provider-specific tools, so we pick the right one based on the underlying provider. """ - # Normalise: "gateway:anthropic/..." → "anthropic", - # "anthropic:..." → "anthropic" - mid = model_id.lower() - if mid.startswith("gateway:"): - mid = mid[len("gateway:") :] - provider = mid.split("/")[0].split(":")[0] + provider = _provider_slug(model_id) if provider == "anthropic": from ai.providers.anthropic import tools as ant From f89ec7d18a6a2cd0a331b33b9ab73ec0b7745518 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Thu, 28 May 2026 12:16:46 -0700 Subject: [PATCH 67/73] [tau] only pin gateway routing for anthropic and openai The named provider is the maker only for anthropic and openai, so pin gateway routing to ''only'' that provider for those two. Open-weight models are served by many backends where the slug provider isn't the maker, so leave their routing unrestricted. --- examples/tau-agent/tau/app.py | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/examples/tau-agent/tau/app.py b/examples/tau-agent/tau/app.py index 243d7165..252e38c9 100644 --- a/examples/tau-agent/tau/app.py +++ b/examples/tau-agent/tau/app.py @@ -58,15 +58,30 @@ def _provider_slug(model_id: str) -> str: return mid.split("/")[0].split(":")[0] -# Only send gateway-specific options when routing through the gateway. Pin the -# gateway to the model's own provider via ``only`` so it never falls back to -# another backend that also serves the model (e.g. Bedrock/Vertex for Claude). +_PROVIDER = _provider_slug(MODEL_ID) + +# Providers whose first-party backend is the model's maker. For these we pin +# the gateway to that provider via ``only`` so it never falls back to another +# backend that also serves the model (e.g. Bedrock/Vertex for Claude). Other +# models (open-weight ones, say) are served by many backends where the named +# provider isn't the maker, so we leave their routing unrestricted. +# +# The other providers often don't support all the server side +# tools. In practice the main way that this becomes a problem is that +# when there is an error on the main provider, gateway tries falling +# back to other providers which then fail with unhelpful errors about +# the tools not existing. +_PIN_PROVIDERS = frozenset({"anthropic", "openai"}) + +# Only send gateway-specific options when routing through the gateway. STREAM_PARAMS: dict[str, Any] | None = ( { "providerOptions": { "gateway": { "caching": "auto", - "only": [_provider_slug(MODEL_ID)], + **( + {"only": [_PROVIDER]} if _PROVIDER in _PIN_PROVIDERS else {} + ), }, "anthropic": { "thinking": {"type": "enabled", "budget_tokens": 10000} From ed1fe385737e5632509118a1e1bc200629d6017a Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Thu, 28 May 2026 12:19:27 -0700 Subject: [PATCH 68/73] [tau] Use adaptive thinking --- examples/tau-agent/tau/app.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/examples/tau-agent/tau/app.py b/examples/tau-agent/tau/app.py index 252e38c9..ffe1a0ea 100644 --- a/examples/tau-agent/tau/app.py +++ b/examples/tau-agent/tau/app.py @@ -84,7 +84,8 @@ def _provider_slug(model_id: str) -> str: ), }, "anthropic": { - "thinking": {"type": "enabled", "budget_tokens": 10000} + "thinking": {"type": "adaptive"}, + "output_config": {"effort": "xhigh"}, }, } } From 49936ec261092efd6bc6dbb00da53f31617bbd35 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Thu, 28 May 2026 12:26:41 -0700 Subject: [PATCH 69/73] [tau] fix: return ContentOutput for image reads The file-part-tool rebase routes non-str, non-ToolResultOutput tool returns through JsonOutput, which can't serialize a FilePart. Wrap image reads in ai.messages.ContentOutput([TextPart, FilePart]) so providers emit a real image content block. Co-authored-by: anthropic/claude-opus-4.8, via tau --- examples/tau-agent/tau/tools.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/examples/tau-agent/tau/tools.py b/examples/tau-agent/tau/tools.py index 7972e2b4..1c7e41dc 100644 --- a/examples/tau-agent/tau/tools.py +++ b/examples/tau-agent/tau/tools.py @@ -240,16 +240,17 @@ async def read( if not p.is_file(): raise IsADirectoryError(f"Not a file: {path}") - # Image files: return a text label + FilePart so providers emit - # a real image content block instead of stringified JSON. + # Image files: return a ContentOutput carrying a text label + a + # FilePart so providers emit a real image content block instead of + # trying (and failing) to JSON-serialize the FilePart. mime = _detect_image_mime(p) if mime is not None: data = p.read_bytes() size_str = format_size(len(data)) - return [ + return ai.content_output( f"Read image file [{mime}, {size_str}]", ai.file_part(data, media_type=mime), - ] + ) text = p.read_text(encoding="utf-8", errors="replace") all_lines = text.split("\n") From a3ccfcae0bf8594f97622ae108213965caafe575 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Thu, 28 May 2026 17:24:20 -0700 Subject: [PATCH 70/73] [tau] include anthropic and openai deps --- examples/tau-agent/pyproject.toml | 2 +- examples/tau-agent/uv.lock | 170 +++++++++++++++++++++++++++++- 2 files changed, 169 insertions(+), 3 deletions(-) diff --git a/examples/tau-agent/pyproject.toml b/examples/tau-agent/pyproject.toml index b2ef1650..cc023037 100644 --- a/examples/tau-agent/pyproject.toml +++ b/examples/tau-agent/pyproject.toml @@ -4,7 +4,7 @@ version = "0.1.0" description = "Tau — a coding-agent chat bot demo built with the ai library and Textual" requires-python = ">=3.12" dependencies = [ - "ai", + "ai[anthropic,openai]", "textual>=3.0", ] diff --git a/examples/tau-agent/uv.lock b/examples/tau-agent/uv.lock index 3b192ac9..7b5c9d5a 100644 --- a/examples/tau-agent/uv.lock +++ b/examples/tau-agent/uv.lock @@ -16,6 +16,14 @@ dependencies = [ { name = "typing-extensions" }, ] +[package.optional-dependencies] +anthropic = [ + { name = "anthropic" }, +] +openai = [ + { name = "openai" }, +] + [package.metadata] requires-dist = [ { name = "anthropic", marker = "extra == 'anthropic'", specifier = ">=0.83.0" }, @@ -58,6 +66,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, ] +[[package]] +name = "anthropic" +version = "0.105.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "docstring-parser" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/46/47581b8c689c743ceabf6a0f9ff48472160900ce802d26c0fb50423997b3/anthropic-0.105.2.tar.gz", hash = "sha256:0e26b90841c2dced7cc6e98d21d5517d0be33f1876b8e779f478202e28bcaa07", size = 853789, upload-time = "2026-05-29T00:21:14.104Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/75/be0c357e33a5a56c8f9db5b4212f886138d2bf59c0952d858f6b75d710ef/anthropic-0.105.2-py3-none-any.whl", hash = "sha256:e53ed5f6bf36fb1ecb9b25d8634cfd30e02fab9fb3374a0c2d5c585874757230", size = 837507, upload-time = "2026-05-29T00:21:15.528Z" }, +] + [[package]] name = "anyio" version = "4.13.0" @@ -120,6 +147,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" }, ] +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "distro" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, +] + +[[package]] +name = "docstring-parser" +version = "0.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/4d/f332313098c1de1b2d2ff91cf2674415cc7cddab2ca1b01ae29774bd5fdf/docstring_parser-0.18.0.tar.gz", hash = "sha256:292510982205c12b1248696f44959db3cdd1740237a968ea1e2e7a900eeb2015", size = 29341, upload-time = "2026-04-14T04:09:19.867Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b", size = 22484, upload-time = "2026-04-14T04:09:18.638Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -166,6 +220,78 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" }, ] +[[package]] +name = "jiter" +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/b5/55f06bb281d92fb3cc86d14e1def2bd908bb77693183e7cb1f5a3c388b0c/jiter-0.15.0.tar.gz", hash = "sha256:4251acc80e2b7c9b7b8823456ea0fceeb0734dac2df7636d3c711b38476b5a76", size = 166640, upload-time = "2026-05-19T10:09:48.361Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/53/4f6bddbcde3c71e56d0aa1337ec95950f3d27dd4153e25aadf0feac71751/jiter-0.15.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:0e90a1c315a0226ec822d973817967f9223b7701546c8c2a7913e7ab0926294d", size = 308793, upload-time = "2026-05-19T10:07:35.25Z" }, + { url = "https://files.pythonhosted.org/packages/01/84/c01099b59a285a1ebba64ae93f62bfa036675340fd1b0045ae65890a0442/jiter-0.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8c9004af7c8d67cce7f1aae1026fb55607f4aa600710d08ede3a3ce4aeefe7e0", size = 309570, upload-time = "2026-05-19T10:07:36.919Z" }, + { url = "https://files.pythonhosted.org/packages/58/64/8fb7f9d45bb98190355454cd04dad8d8f27223d6bd52f83af07f637168a6/jiter-0.15.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c210f8b35dc6f30aafd4b4365ca89b9d1189f21ab49b8e68fa6322a847aef138", size = 336783, upload-time = "2026-05-19T10:07:38.694Z" }, + { url = "https://files.pythonhosted.org/packages/c3/b6/f5739011d009b3a30f6a53c5240979030ba29ae46a8c67e3a15759f7c37d/jiter-0.15.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f30bae8bc1c2d613e28e5af3e8cceb09b742f1c8a8a5f839fb67afaffc03b61", size = 363555, upload-time = "2026-05-19T10:07:40.832Z" }, + { url = "https://files.pythonhosted.org/packages/e5/12/98a9d9f766665e8a3b6252454e17cb0c464606a28cf2fa09399b003345fa/jiter-0.15.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c60e71b6d10cfc284c9bf36bd885e8d44c46f688ce50aa91b5edd90181dea687", size = 452255, upload-time = "2026-05-19T10:07:42.62Z" }, + { url = "https://files.pythonhosted.org/packages/e8/d5/60f972840f79c5e7544fce567c56f1e4e50468f996baba3e78d823dd62a6/jiter-0.15.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ab068bce62a45aa3e7367eceaffb5dde60b7eb853be8dece45132e3d0ff4879", size = 373559, upload-time = "2026-05-19T10:07:44.201Z" }, + { url = "https://files.pythonhosted.org/packages/ee/cf/d46ef1234ba335aabc2f013210db8e0821a22f5e644a2e9449df199ecc23/jiter-0.15.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa248c9eb220197d363f688818dac2fd4b2f0cd7d843ca7105d652034823427d", size = 346055, upload-time = "2026-05-19T10:07:46.005Z" }, + { url = "https://files.pythonhosted.org/packages/f0/63/4d2749d8d54d230bad9b3a6b0d00cc28c6ff6b2fdffc26a8ccf76cc5a974/jiter-0.15.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2a77aadd57cac1682e4401a72724d2796d89a4ba129b1a5812aa94ee480826eb", size = 351406, upload-time = "2026-05-19T10:07:47.855Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b9/9965b990035d8773328e0a8c8b457a87bf2b19f6c4126d9d99296be5d16a/jiter-0.15.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2ae901f3a55bfafdde31d289590fa25e3245735a2b1e8c7cc15871710a002871", size = 389357, upload-time = "2026-05-19T10:07:49.665Z" }, + { url = "https://files.pythonhosted.org/packages/2d/55/9ddf903deda1413e87fed792f416b7123daee5b8efbad6a202a7421c36a5/jiter-0.15.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:f0b271b462769543716f92d3a4f90527df6ef5ed05ee95ec4137f513e21e1b77", size = 517263, upload-time = "2026-05-19T10:07:51.537Z" }, + { url = "https://files.pythonhosted.org/packages/e8/76/a0c40ad064d3a20a4fde231e35d56e9a01ce82164278180e82d5daf85469/jiter-0.15.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2fb6a5d26af81fc0f00f9360a891e05cf755e149bba391c4d563adc54812973d", size = 548646, upload-time = "2026-05-19T10:07:53.196Z" }, + { url = "https://files.pythonhosted.org/packages/23/4f/eca9b954942916ba2f453891b8593ab444cd872396fe66a3936616f236f3/jiter-0.15.0-cp312-cp312-win32.whl", hash = "sha256:c2f6bb8b5216ab9e7873bc08b5d7bef2b8abbb578a3069bf1cd14a45d71d771d", size = 206427, upload-time = "2026-05-19T10:07:55.307Z" }, + { url = "https://files.pythonhosted.org/packages/95/bf/8ead82a87495149542748e828d153fd232a512a22c83b02c4815c1a9c7d8/jiter-0.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:40b2c7e92c44a84d748d21706c68dc6ff8161d80b59c99d774721a0d2317d7c7", size = 197300, upload-time = "2026-05-19T10:07:56.651Z" }, + { url = "https://files.pythonhosted.org/packages/f4/e4/9b8a78fb2d894471bc344e37f1949bdd784bd914d031dba0ba3a40c71dd7/jiter-0.15.0-cp312-cp312-win_arm64.whl", hash = "sha256:cc0bc345cf2df9d1c00ac443f50d543c1ccfa8b0422cb85b1ab70d681c0b255b", size = 192702, upload-time = "2026-05-19T10:07:58.307Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f4/f708c900ecee41b2025ef8413d5351e5649eb2125c506f6720cc69b06f5c/jiter-0.15.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1c11465f97e2abf45a014b83b730222f8f1c5335e802c7055a67d50de6f1f4e3", size = 307829, upload-time = "2026-05-19T10:07:59.704Z" }, + { url = "https://files.pythonhosted.org/packages/86/59/db537c0949e83668c38481d426b9f2fd5ab758c4ee53a811dd0a510626a0/jiter-0.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d1e7b1776f0797956c509e123d0952d10d293a9492dea9f288ab9570ec01d1a5", size = 308445, upload-time = "2026-05-19T10:08:01.184Z" }, + { url = "https://files.pythonhosted.org/packages/37/38/ea0e13b18c30ef951da0d47d39e7fa9edb82a93a62990ffbd7cea9b622d4/jiter-0.15.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:351a341c2105aa430b7047e30f1bf7975f6313b00165d3fc07be2edaf741f279", size = 336181, upload-time = "2026-05-19T10:08:02.688Z" }, + { url = "https://files.pythonhosted.org/packages/58/fc/2303901b16c4ba05865588990a420c0b4156270b44379c20931544a1d962/jiter-0.15.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4ab395feec8d249ec4044e228e98a7033f043426a265df439dc3698823f0a4e4", size = 362985, upload-time = "2026-05-19T10:08:04.394Z" }, + { url = "https://files.pythonhosted.org/packages/5b/6f/11bace093c52e7d4d26c8e606ccd7ae8c972189622469ec0d9e28161e28b/jiter-0.15.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a2a438005b6f22d0273413484d6094d7c2c5d10ec1b3a3bf128e0d1d3ba53258", size = 453292, upload-time = "2026-05-19T10:08:05.967Z" }, + { url = "https://files.pythonhosted.org/packages/22/db/987f2f086ca4d7a6582eb4ccd513f9b26b42d9e4243a087609a3137a8fc7/jiter-0.15.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f18f85e4218d1b40f000f42a92239a7a61a902cd42c65e6c360dbd17dcb20894", size = 373501, upload-time = "2026-05-19T10:08:07.857Z" }, + { url = "https://files.pythonhosted.org/packages/8f/7c/89fbcabb2739b7a5b8dc959a1b6c5761f6484f5fed3486854b3c789bb1de/jiter-0.15.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d1aa62e277fc1cbd80e6deacae6f4d983b41b3d7728e0645c5d741a6149bba45", size = 344683, upload-time = "2026-05-19T10:08:09.431Z" }, + { url = "https://files.pythonhosted.org/packages/30/6f/6cca7692e7dddfec6d8d76c54dc97f2af2a41df4ac0674b999df1f09a5f3/jiter-0.15.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:6550fa135c7deb8ead6af49ed7ff648532ea8334a1447fe34a36315ef79c5c29", size = 350892, upload-time = "2026-05-19T10:08:11.352Z" }, + { url = "https://files.pythonhosted.org/packages/39/14/0338d6190cb8e6d22e677ab1d4eabd4117f67cca70c54cd04b82ff64e068/jiter-0.15.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:066f8f33f18b2419cd8213b2436fa7fbc9c499f315971cfa3ce1f9820c001b1b", size = 388723, upload-time = "2026-05-19T10:08:12.912Z" }, + { url = "https://files.pythonhosted.org/packages/90/31/cc19f4a1bdb6afb09ce6a2f2615aa8d44d994eba0d8e6105ed1af920e736/jiter-0.15.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:75e8a04e91432dde9f1838373cf93d23726c79d3e908d319acf0e796f85592e7", size = 516648, upload-time = "2026-05-19T10:08:14.808Z" }, + { url = "https://files.pythonhosted.org/packages/49/9f/833c541512cd091b63c10c0381973dfe11bc7a503a818c16384417e0c81e/jiter-0.15.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:a97261f1fccb8e50ecd2890a96e46efdc3f57c80a197324c6777827231eca712", size = 547382, upload-time = "2026-05-19T10:08:16.927Z" }, + { url = "https://files.pythonhosted.org/packages/d2/11/e7b70e91f90bc4477e8eee9e8a5f7cf3cb41b4525d6394dc98a714eb8f7f/jiter-0.15.0-cp313-cp313-win32.whl", hash = "sha256:c77496cb10bd7549690fbbab3e5ec05857b83e49276f4a9423a766ddd2afcd4c", size = 205845, upload-time = "2026-05-19T10:08:18.401Z" }, + { url = "https://files.pythonhosted.org/packages/4b/23/5c20d9ad6f02c493e4023e5d2d09e1c1f15fe2753c9102c544aff068a88e/jiter-0.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:b15741f501469009ae0ae90b7147958a664a7dede40aa7ff174a8a4645f546d0", size = 196842, upload-time = "2026-05-19T10:08:20.131Z" }, + { url = "https://files.pythonhosted.org/packages/6b/11/1eb400ef248e8c925fd883fbe325daf5e42cd1b0d308539dd332bd4f7ffc/jiter-0.15.0-cp313-cp313-win_arm64.whl", hash = "sha256:5d6a60072b44c3c2b797a7ddcbcbbf2b34ea3cfd4721580fbfd2a09d9d9b84ba", size = 192212, upload-time = "2026-05-19T10:08:21.807Z" }, + { url = "https://files.pythonhosted.org/packages/8a/60/2fd8d7c79da8acf9b7b277c7616847773779356b92acfc9bb158452174da/jiter-0.15.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:ef1fd24d9413f6209e00d3d5a453e67acfe004a25cc6c8e8484faed4311ab9e8", size = 315065, upload-time = "2026-05-19T10:08:23.218Z" }, + { url = "https://files.pythonhosted.org/packages/46/f4/008fb7d65e8ac2abf00811651a661e025c4ba80bbc6f378450384ddd3aed/jiter-0.15.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:144f8e72cb53dab146347b91cceac01f5481237f2b93b4a339a1ee8f8878b67c", size = 339444, upload-time = "2026-05-19T10:08:24.701Z" }, + { url = "https://files.pythonhosted.org/packages/00/55/90b0c7b9c6896c0f2a591dd36d36b71d22e09674bfef178fa03ba3f81499/jiter-0.15.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:553fcac2ef2cb990877f9fc0833b8b629a3e6a5670b6b5fd58219b41a653ddc4", size = 347779, upload-time = "2026-05-19T10:08:26.408Z" }, + { url = "https://files.pythonhosted.org/packages/51/6b/69666cec5000fd57734c118437394516c749ae8dbeea9fb66d6fef9c4775/jiter-0.15.0-cp313-cp313t-win_amd64.whl", hash = "sha256:774f93f65031856bf14ad9f59bdcab8b8cad501e5ceabd51ba3525f76937a25b", size = 200395, upload-time = "2026-05-19T10:08:28.055Z" }, + { url = "https://files.pythonhosted.org/packages/39/04/a6aa62cd27e8149b0d28df5561f10f6cceaf7935a9ccf3f1c5a05f9a0cd8/jiter-0.15.0-cp313-cp313t-win_arm64.whl", hash = "sha256:f1e1754960f38ec40613a07e5e372df67acb3b890fb383b6fb3de3e49ddbf3c7", size = 190516, upload-time = "2026-05-19T10:08:29.35Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d2/079f350ebf7859d081de30aa890f9e3be68516f754f3ba32366ffff4dcee/jiter-0.15.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:ac0d9ddea4350974be7a221fc25895f251a8fee748c889bdced2141c0fec1a49", size = 308884, upload-time = "2026-05-19T10:08:31.667Z" }, + { url = "https://files.pythonhosted.org/packages/04/4e/a2c30a7f69b48c03b20935d647479106fe932f6e63f75faf53937197e05d/jiter-0.15.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:01a8222cf05ab1128e239421156c207949808acaaea2bdfd33130ae666786e86", size = 310028, upload-time = "2026-05-19T10:08:33.304Z" }, + { url = "https://files.pythonhosted.org/packages/40/90/2e7cdfd3cf8ca967be38c48f5cf474d79f089efaf559a40f15984a77ae69/jiter-0.15.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:182226cbc930c9fab81bc2e41a4da672f89539906dadb05e75670ac07b94f71f", size = 337485, upload-time = "2026-05-19T10:08:35.259Z" }, + { url = "https://files.pythonhosted.org/packages/9b/11/15a1aa28b120b8ee5b4f1fb894c125046225f09847738bd64233d3b84883/jiter-0.15.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:71683c38c825452999b5717fcae07ea708e8c93003e808be4319c1b02e3d176e", size = 364223, upload-time = "2026-05-19T10:08:36.694Z" }, + { url = "https://files.pythonhosted.org/packages/b7/25/f442e8af5f3d0dcf47b39e83a0efd9ee45ea946aa6d04625dc3181eae3b6/jiter-0.15.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:30f2218e6a9e5c18bc10fe6d41ac189c442c88eacf11bad9f28ef95a9bef00e6", size = 456387, upload-time = "2026-05-19T10:08:38.143Z" }, + { url = "https://files.pythonhosted.org/packages/da/f4/37f2d2c9f64f49af7da652ed7532bb5a2372e588e6927c3fdd76f911db65/jiter-0.15.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5157de9f76eb4bc5ea74a1219366a25f945ad305641d74e04f59c54087091aa9", size = 374461, upload-time = "2026-05-19T10:08:39.869Z" }, + { url = "https://files.pythonhosted.org/packages/60/28/edcfbbbf0cb15436f36664a8908a0df47ab9006298d4cd937dc08ea932d6/jiter-0.15.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90c5db5527c221249a876160663ab891ace358c17f7b9c93ec1478b7f0550e5c", size = 345924, upload-time = "2026-05-19T10:08:41.668Z" }, + { url = "https://files.pythonhosted.org/packages/47/13/89fba6398dab7f202b7278c4b4aac122399d2c0183971c4a57a3b7088df5/jiter-0.15.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:3e4540b8e74e4268811ac05db226a6a128ff572e7e0ce3f1163b693cadb184cd", size = 352283, upload-time = "2026-05-19T10:08:43.091Z" }, + { url = "https://files.pythonhosted.org/packages/1b/da/0f6af8cef2c565a1ab44d970f268c43ccaa72707386ea6388e6fe2b6cd26/jiter-0.15.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:62ebd14e47e9aed9df4472afcb2663668ce4d74891cd54f86bf6e44029d6dc89", size = 389985, upload-time = "2026-05-19T10:08:44.915Z" }, + { url = "https://files.pythonhosted.org/packages/a1/ec/b9cb7d6d29e24ee14910266157d2a279d7a8f60ee0df7fa840882976ba64/jiter-0.15.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0be6f5ad41a809f303f416d17cec92a7a725902fb9b4f3de3d19362ac0ef8554", size = 517695, upload-time = "2026-05-19T10:08:46.486Z" }, + { url = "https://files.pythonhosted.org/packages/64/5e/6d1bda880723aae0ad86b4b763f044362448efe31e3e819635d41cb03451/jiter-0.15.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:813dfbb17d65328bf86e5f0905dd277ba2265d3ca20556e86c0c7035b7182e5a", size = 548868, upload-time = "2026-05-19T10:08:48.026Z" }, + { url = "https://files.pythonhosted.org/packages/0c/72/7de501cf38dcacaf35098796f3a50e0f2e338baba18a58946c618544b809/jiter-0.15.0-cp314-cp314-win32.whl", hash = "sha256:50e51156192722a9c58db112837d3f8ef96fb3c5ecc14e95f409134b08b158ec", size = 206380, upload-time = "2026-05-19T10:08:49.738Z" }, + { url = "https://files.pythonhosted.org/packages/1e/a9/e19addf4b0c1bdce52c6da12351e6bc42c340c45e7c09e2158e46d293ccc/jiter-0.15.0-cp314-cp314-win_amd64.whl", hash = "sha256:30ce1a5d16b5641dc935d50ef775af6a0871e3d14ab05d6fc54dff371b78e558", size = 197687, upload-time = "2026-05-19T10:08:51.088Z" }, + { url = "https://files.pythonhosted.org/packages/f2/c9/776b1db01db25fc6c1d58d1979a37b0a9fe787e5f5b1d062d2eaacb77923/jiter-0.15.0-cp314-cp314-win_arm64.whl", hash = "sha256:510c8b3c17a0ed9ac69850c0438dada3c9b82d9c4d589fcb62002a5a9cf3a866", size = 192571, upload-time = "2026-05-19T10:08:52.451Z" }, + { url = "https://files.pythonhosted.org/packages/a0/f6/45bb4670bacf300fd2c7abadbfb3af376e5f1b6ae75fd9bc069891d15870/jiter-0.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7553333dd0930c104a5a0db8df72bf7219fe663d731383b576bb6ed6351c984d", size = 317151, upload-time = "2026-05-19T10:08:53.867Z" }, + { url = "https://files.pythonhosted.org/packages/d7/68/ed635ad5acd7b73e454283083bbb7c8205ad10e88b0d9d7d793b09fe8226/jiter-0.15.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2143ab06181d2b029eedcb6af3cebe95f11bbac62441781860f98ee9330a6a6", size = 341243, upload-time = "2026-05-19T10:08:55.383Z" }, + { url = "https://files.pythonhosted.org/packages/5d/db/3ff4176b817b8ea33879e71e13d8bc2b0d481a7ed3fe9e080f333d415c16/jiter-0.15.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6eac374c5c975709b69c10f09afd199df74150172156ad10c8d4fd785b7da995", size = 363629, upload-time = "2026-05-19T10:08:56.928Z" }, + { url = "https://files.pythonhosted.org/packages/ab/24/5f8270e0ba9c883582f96f722f8a0b58015c7ce1f8c6d4571cf394e99b6b/jiter-0.15.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b3b3b775e33d3bfaec9899edc526ae97b0da0bf9d071a46124ba419149a414f8", size = 456198, upload-time = "2026-05-19T10:08:58.618Z" }, + { url = "https://files.pythonhosted.org/packages/45/5b/76fc02b0b5c54c3d18c60653156e2f76fde1816f9b4722db68d6ee2c897e/jiter-0.15.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eda3071db3346334beae1360b46da4606da57bf3528c167b3c38533afaf9f2c5", size = 373710, upload-time = "2026-05-19T10:09:00.151Z" }, + { url = "https://files.pythonhosted.org/packages/c4/52/4310821b0ea9277994d3e1f49fc6a4b34e4800caebacb2c0af81da59a454/jiter-0.15.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c6694a173ecabc12eb60efbc0b474464ead1951ff65cd8b1e72100715c64512b", size = 349901, upload-time = "2026-05-19T10:09:01.621Z" }, + { url = "https://files.pythonhosted.org/packages/93/fe/67648c35b3594fba8854ac64cc8a826d8bcd18324bbdb53d77697c60b6ef/jiter-0.15.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:a254e10b593624d230c365b6d616b22ca0ad65e63a16e6631c2b3466022e6ba8", size = 352438, upload-time = "2026-05-19T10:09:03.216Z" }, + { url = "https://files.pythonhosted.org/packages/cb/28/0a1879d07ad6b3e025a2750027363452ced93c2d16d1c9d4b153ffd51c91/jiter-0.15.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d8d2955167274e15d79a7a020afdd9b39c990eb80b2d89fca695d92dcfdd38ec", size = 388152, upload-time = "2026-05-19T10:09:04.741Z" }, + { url = "https://files.pythonhosted.org/packages/c1/78/46c6f6b56ba85c90021f4afd72ed42f691f8f84daacb5fe27277070e3858/jiter-0.15.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:acf4ee4d1fc55917239fe72972fb292dd773055d05eb040d36f4326e02cc2c0e", size = 517707, upload-time = "2026-05-19T10:09:06.231Z" }, + { url = "https://files.pythonhosted.org/packages/ca/cb/720662d4c88fcad606e826fef5424365527ba43ce4868a479aed8f8c507e/jiter-0.15.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:e7196e56f1cd69af1dbb07dff02dcfb260a50b45a82d409d92a06fedb32473b5", size = 548241, upload-time = "2026-05-19T10:09:08.093Z" }, + { url = "https://files.pythonhosted.org/packages/60/e3/935b8034fd143f21125c87d51404a9e0e1449186a494405721ff5d1d695e/jiter-0.15.0-cp314-cp314t-win32.whl", hash = "sha256:7f6163c0f10b055245f814dcc59f4818da60dfe72f3e72ab89fc24b6bd5e9c52", size = 207950, upload-time = "2026-05-19T10:09:09.616Z" }, + { url = "https://files.pythonhosted.org/packages/93/59/984fd9ece895953dad3e0880a650e766f5a2da2c5514f0eafdaaabbeb5f9/jiter-0.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:980c256edb05b78a111b99c4de3b1d32e31634b867fd1fc2cf726e7b7bba9854", size = 200055, upload-time = "2026-05-19T10:09:11.367Z" }, + { url = "https://files.pythonhosted.org/packages/0e/a4/cf8d779feb133a27a2e3bc833bccb9e13aa332cdf820497ebf72c10ce8c3/jiter-0.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:66b1880df2d01e206e8339769d1c7c1753bcb653efd6289e203f6f24ebada0c0", size = 191244, upload-time = "2026-05-19T10:09:12.74Z" }, + { url = "https://files.pythonhosted.org/packages/73/38/505941b2b092fd5bbbd60a52a880db1173f1690ae6751bed3af1c9ddcb4e/jiter-0.15.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:631f13a3d04e97d4e083993b10f4b99530e3a10d953e2eb5e196b7dc7f812ce0", size = 303769, upload-time = "2026-05-19T10:09:42.203Z" }, + { url = "https://files.pythonhosted.org/packages/e7/95/a06692b29e77473f286e1ec1f426d3ca44d7b5843be8ad21d7a5f3fcdcc0/jiter-0.15.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:b6c0ffae686c39bf3737be60793783267628783ea42545632c10b291105aee45", size = 305128, upload-time = "2026-05-19T10:09:43.657Z" }, + { url = "https://files.pythonhosted.org/packages/23/85/7270d7ad41d6061a25b950c6bf91d638bd9aacb113200a8c8d57a055fd67/jiter-0.15.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d54fb5b31dea401a41af3f8a7d2512e9b6a6a005491e6166c7e4ffab9639a9c", size = 340459, upload-time = "2026-05-19T10:09:45.452Z" }, + { url = "https://files.pythonhosted.org/packages/c8/8d/302cb2057b7513327b4d575cff6b1d066ee6431a5357fc3f8867cd684406/jiter-0.15.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d5d6090cdc1b7c9e780dfb04949a990adb1e301a2fc0bbcee7de4638d33f9a", size = 344469, upload-time = "2026-05-19T10:09:46.864Z" }, +] + [[package]] name = "librt" version = "0.11.0" @@ -338,6 +464,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] +[[package]] +name = "openai" +version = "2.38.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8f/12/cfa322c5f5dd8fa21aab9a7a8e979e7a11123800f86ca8d82eb68a83d213/openai-2.38.0.tar.gz", hash = "sha256:798694c6cf74145541fda94325b6f8f72d8e1fd0262cc137c8d728177a6a4ce3", size = 772764, upload-time = "2026-05-21T21:23:42.105Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/bf/ccff9be562e24207716d04ef9dc931c76aff0c89a7265da43e2104d7fe06/openai-2.38.0-py3-none-any.whl", hash = "sha256:ec6661c57b2dcc47414a767e6e3335c7ed3d19c9696999283a3c82e95c756a3c", size = 1344910, upload-time = "2026-05-21T21:23:39.636Z" }, +] + [[package]] name = "pathspec" version = "1.1.1" @@ -493,12 +638,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9b/36/9c015cd052fca743dae8cb2aeb16b551444787467db42ceab0fc968865af/ruff-0.15.13-py3-none-win_arm64.whl", hash = "sha256:2471da9bd1068c8c064b5fd9c0c4b6dddffd6369cb1cd68b29993b1709ff1b21", size = 11179336, upload-time = "2026-05-14T13:44:33.026Z" }, ] +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + [[package]] name = "tau-agent" version = "0.1.0" source = { editable = "." } dependencies = [ - { name = "ai" }, + { name = "ai", extra = ["anthropic", "openai"] }, { name = "textual" }, ] @@ -510,7 +664,7 @@ dev = [ [package.metadata] requires-dist = [ - { name = "ai", editable = "../../" }, + { name = "ai", extras = ["anthropic", "openai"], editable = "../../" }, { name = "textual", specifier = ">=3.0" }, ] @@ -537,6 +691,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b8/b4/c2b876f445e52522824cb900f2c7db3a7c24f89d20449ef278b4195d0ecb/textual-8.2.6-py3-none-any.whl", hash = "sha256:17c92bec7ff1617bd7db2a3d9734b0c3b7d2c274c67d5eba94371ea2f99a63fd", size = 729855, upload-time = "2026-05-13T09:56:14.687Z" }, ] +[[package]] +name = "tqdm" +version = "4.67.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0" From 001b72bd72b97cf1a33ba310d97e9871c858da48 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Thu, 28 May 2026 19:57:38 -0700 Subject: [PATCH 71/73] [tau] Tweak anthropic thinking settings --- examples/tau-agent/tau/app.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/examples/tau-agent/tau/app.py b/examples/tau-agent/tau/app.py index ffe1a0ea..fc7320d5 100644 --- a/examples/tau-agent/tau/app.py +++ b/examples/tau-agent/tau/app.py @@ -73,7 +73,13 @@ def _provider_slug(model_id: str) -> str: # the tools not existing. _PIN_PROVIDERS = frozenset({"anthropic", "openai"}) -# Only send gateway-specific options when routing through the gateway. +ANT_PARAMS = { + "thinking": { + "type": "adaptive", + "display": "summarized", + }, + "output_config": {"effort": "high"}, +} STREAM_PARAMS: dict[str, Any] | None = ( { "providerOptions": { @@ -83,13 +89,12 @@ def _provider_slug(model_id: str) -> str: {"only": [_PROVIDER]} if _PROVIDER in _PIN_PROVIDERS else {} ), }, - "anthropic": { - "thinking": {"type": "adaptive"}, - "output_config": {"effort": "xhigh"}, - }, + "anthropic": ANT_PARAMS, } } if MODEL_ID.startswith("gateway:") + else ANT_PARAMS + if MODEL_ID.startswith("anthropic:") else None ) From 9881f4e56055db604d717b74a340786e5c08e34e Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Thu, 28 May 2026 20:02:44 -0700 Subject: [PATCH 72/73] [tau] feat: render ContentOutput tool results as text _format_tool_result JSON-dumped non-str results, splattering the base-64 blob from a read's ContentOutput into the transcript. Add a ContentOutput branch that shows only its text parts (the tool already includes a human-readable label), keeping image data out of the bubble. Co-authored-by: anthropic/claude-opus-4.8, via tau --- examples/tau-agent/tau/app.py | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/examples/tau-agent/tau/app.py b/examples/tau-agent/tau/app.py index fc7320d5..c2adbba2 100644 --- a/examples/tau-agent/tau/app.py +++ b/examples/tau-agent/tau/app.py @@ -420,12 +420,27 @@ def _json_default(obj: Any) -> Any: ) -def _format_tool_result(result: Any, is_error: bool) -> str: - text = ( - result - if isinstance(result, str) - else json.dumps(result, ensure_ascii=False, default=_json_default) +def _content_to_text(output: ai.messages.ContentOutput) -> str: + """Render a multipart ``ContentOutput`` for the transcript. + + Only the text parts are shown; file parts (e.g. images from + ``read``) carry base-64 blobs we don't want in the bubble, and the + tool already includes a human-readable label as a text part. + """ + return "\n".join( + part.text + for part in output.value + if isinstance(part, ai.messages.TextPart) ) + + +def _format_tool_result(result: Any, is_error: bool) -> str: + if isinstance(result, ai.messages.ContentOutput): + text = _content_to_text(result) + elif isinstance(result, str): + text = result + else: + text = json.dumps(result, ensure_ascii=False, default=_json_default) if len(text) > RESULT_PREVIEW_CHARS: text = ( text[:RESULT_PREVIEW_CHARS] From af816dfb14e6de08c7daad6f391a0490d37713c5 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Fri, 29 May 2026 12:41:17 -0700 Subject: [PATCH 73/73] [tau] Use ai.InferenceRequestParams to configure model --- examples/tau-agent/tau/app.py | 58 ++++++++++++----------------------- 1 file changed, 20 insertions(+), 38 deletions(-) diff --git a/examples/tau-agent/tau/app.py b/examples/tau-agent/tau/app.py index c2adbba2..5e2ecb3e 100644 --- a/examples/tau-agent/tau/app.py +++ b/examples/tau-agent/tau/app.py @@ -44,6 +44,8 @@ _raw_model = os.environ.get("TAU_MODEL", "gateway:anthropic/claude-opus-4.8") MODEL_ID = _raw_model if ":" in _raw_model else f"gateway:{_raw_model}" +EFFORT = os.environ.get("TAU_EFFORT", "high") + def _provider_slug(model_id: str) -> str: """Extract the backend provider slug from a model id. @@ -58,44 +60,24 @@ def _provider_slug(model_id: str) -> str: return mid.split("/")[0].split(":")[0] -_PROVIDER = _provider_slug(MODEL_ID) - -# Providers whose first-party backend is the model's maker. For these we pin -# the gateway to that provider via ``only`` so it never falls back to another -# backend that also serves the model (e.g. Bedrock/Vertex for Claude). Other -# models (open-weight ones, say) are served by many backends where the named -# provider isn't the maker, so we leave their routing unrestricted. -# -# The other providers often don't support all the server side -# tools. In practice the main way that this becomes a problem is that -# when there is an error on the main provider, gateway tries falling -# back to other providers which then fail with unhelpful errors about -# the tools not existing. -_PIN_PROVIDERS = frozenset({"anthropic", "openai"}) - -ANT_PARAMS = { - "thinking": { - "type": "adaptive", - "display": "summarized", - }, - "output_config": {"effort": "high"}, +OUTPUT_PARAMS = dict( + anthropic=ai.OutputParams(reasoning_summary="summarized"), + openai=ai.OutputParams(reasoning_summary="detailed"), +) +# For providers with server side tools that we use, we want to route +# them only to their actual provider, since the fallbacks don't +# support the tools. +ROUTING_PARAMS = { + k: ai.RoutingParams(provider_allowlist=frozenset({k})) + for k in {"anthropic", "openai"} } -STREAM_PARAMS: dict[str, Any] | None = ( - { - "providerOptions": { - "gateway": { - "caching": "auto", - **( - {"only": [_PROVIDER]} if _PROVIDER in _PIN_PROVIDERS else {} - ), - }, - "anthropic": ANT_PARAMS, - } - } - if MODEL_ID.startswith("gateway:") - else ANT_PARAMS - if MODEL_ID.startswith("anthropic:") - else None + +PROVIDER = _provider_slug(MODEL_ID) +PARAMS = ai.InferenceRequestParams( + cache=ai.CacheParams(mode="auto"), + reasoning=ai.ReasoningParams(effort=EFFORT), + output=OUTPUT_PARAMS.get(PROVIDER), + routing=ROUTING_PARAMS.get(PROVIDER), ) @@ -323,7 +305,7 @@ async def _run_turn(app: TauApp) -> None: """Execute a single agent turn, dispatching events to the app.""" interrupted = False async with app.agent.run( - app.model, app.session.messages, params=STREAM_PARAMS + app.model, app.session.messages, params=PARAMS ) as stream: try: async for event in stream: